diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 361db704..459dc8b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: LC_ALL: en_US.UTF-8 strategy: - matrix: { ruby: ['3.2', '3.3', '3.4'] } + matrix: { ruby: ['3.2', '3.3', '3.4', '4.0'] } steps: - name: Checkout code diff --git a/bashly.gemspec b/bashly.gemspec index 6074b865..a45fabc9 100644 --- a/bashly.gemspec +++ b/bashly.gemspec @@ -17,16 +17,17 @@ Gem::Specification.new do |s| s.add_dependency 'colsole', '~> 1.0' s.add_dependency 'completely', '~> 0.7.0' - s.add_dependency 'filewatcher', '~> 2.0' s.add_dependency 'gtx', '~> 0.1.1' + s.add_dependency 'listen', '~> 3.9' s.add_dependency 'lp', '~> 0.2.0' - s.add_dependency 'mister_bin', '~> 0.8.1' + s.add_dependency 'mister_bin', '~> 0.9.0' s.add_dependency 'requires', '~> 1.1' s.add_dependency 'tty-markdown', '~> 0.7.2' - # Sub-dependenceis (Ruby 3.3.5 warnings) - s.add_dependency 'logger', '>= 1', '< 3' # required by filewatcher - s.add_dependency 'ostruct', '>= 0', '< 2' # required by json + # Missing sub-dependencies + # logger: required and not bundled by `listen` 3.9.0 + # ref: https://github.com/guard/listen/issues/591 + s.add_dependency 'logger', '~> 1.7' s.metadata = { 'bug_tracker_uri' => 'https://github.com/bashly-framework/bashly/issues', diff --git a/examples/render-mandoc/docs/download.1 b/examples/render-mandoc/docs/download.1 index 76e19da7..94d4cf11 100644 --- a/examples/render-mandoc/docs/download.1 +++ b/examples/render-mandoc/docs/download.1 @@ -1,6 +1,6 @@ .\" Automatically generated by Pandoc 3.2 .\" -.TH "download" "1" "December 2025" "Version 0.1.0" "Sample application" +.TH "download" "1" "January 2026" "Version 0.1.0" "Sample application" .SH NAME \f[B]download\f[R] \- Sample application .SH SYNOPSIS diff --git a/examples/render-mandoc/docs/download.md b/examples/render-mandoc/docs/download.md index 552fac15..77792c34 100644 --- a/examples/render-mandoc/docs/download.md +++ b/examples/render-mandoc/docs/download.md @@ -1,6 +1,6 @@ % download(1) Version 0.1.0 | Sample application % Lana Lang -% December 2025 +% January 2026 NAME ================================================== diff --git a/lib/bashly.rb b/lib/bashly.rb index a934b5c1..e82d8593 100644 --- a/lib/bashly.rb +++ b/lib/bashly.rb @@ -12,7 +12,7 @@ module Bashly autoloads 'bashly', %i[ CLI Config ConfigValidator Library LibrarySource LibrarySourceConfig - MessageStrings RenderContext RenderSource Settings VERSION + MessageStrings RenderContext RenderSource Settings VERSION Watch ] autoloads 'bashly/concerns', %i[ diff --git a/lib/bashly/commands/generate.rb b/lib/bashly/commands/generate.rb index 0cdb673f..daf259c8 100644 --- a/lib/bashly/commands/generate.rb +++ b/lib/bashly/commands/generate.rb @@ -1,5 +1,3 @@ -require 'filewatcher' - module Bashly module Commands class Generate < Base @@ -41,7 +39,7 @@ def run def watch quiet_say "g`watching` #{Settings.source_dir}\n" - Filewatcher.new([Settings.source_dir]).watch do + Watch.new(Settings.source_dir).on_change do reset generate rescue Bashly::ConfigurationError => e diff --git a/lib/bashly/commands/render.rb b/lib/bashly/commands/render.rb index 0e5aafb8..83c1c3bb 100644 --- a/lib/bashly/commands/render.rb +++ b/lib/bashly/commands/render.rb @@ -1,4 +1,3 @@ -require 'filewatcher' require 'tty-markdown' module Bashly @@ -75,7 +74,7 @@ def render def watch say "g`watching`\n" - Filewatcher.new(watchables).watch do + Watch.new(*watchables).on_change do render say "g`waiting`\n" end diff --git a/lib/bashly/watch.rb b/lib/bashly/watch.rb new file mode 100644 index 00000000..f4b53e0c --- /dev/null +++ b/lib/bashly/watch.rb @@ -0,0 +1,52 @@ +require 'listen' + +module Bashly + # File system watcher - an ergonomic wrapper around the Listen gem + class Watch + attr_reader :dirs, :options + + DEFAULT_OPTIONS = { + force_polling: true, + latency: 1.0, + }.freeze + + def initialize(*dirs, **options) + @options = DEFAULT_OPTIONS.merge(options).freeze + @dirs = dirs.empty? ? ['.'] : dirs + end + + def on_change(&) + start(&) + wait + ensure + stop + end + + private + + def build_listener + listen.to(*dirs, **options) do |modified, added, removed| + yield changes(modified, added, removed) + end + end + + def start(&block) + raise ArgumentError, 'block required' unless block + + @listener = build_listener(&block) + @listener.start + end + + def stop + @listener&.stop + @listener = nil + end + + def changes(modified, added, removed) + { modified:, added:, removed: } + end + + def listen = Listen + def wait = sleep + end +end diff --git a/spec/approvals/examples/dependencies-alt b/spec/approvals/examples/dependencies-alt index 25b7ef40..60f5ebb2 100644 --- a/spec/approvals/examples/dependencies-alt +++ b/spec/approvals/examples/dependencies-alt @@ -24,4 +24,4 @@ args: none deps: - ${deps[git]} = /usr/bin/git - ${deps[http_client]} = /usr/bin/curl -- ${deps[ruby]} = /home/vagrant/.rbenv/versions/3.4.1/bin/ruby +- ${deps[ruby]} = /home/vagrant/.rbenv/versions/4.0.0/bin/ruby diff --git a/spec/bashly/commands/generate_spec.rb b/spec/bashly/commands/generate_spec.rb index d1ba9c5b..811808b6 100644 --- a/spec/bashly/commands/generate_spec.rb +++ b/spec/bashly/commands/generate_spec.rb @@ -229,11 +229,11 @@ let(:bashly_config_path) { "#{source_dir}/bashly.yml" } let(:bashly_config) { YAML.load_file bashly_config_path } - let(:watcher_double) { instance_double Filewatcher, watch: nil } + let(:watch_double) { instance_double Watch, on_change: nil } it 'generates immediately and on change' do - allow(Filewatcher).to receive(:new).and_return(watcher_double) - allow(watcher_double).to receive(:watch).and_yield + allow(Watch).to receive(:new).and_return(watch_double) + allow(watch_double).to receive(:on_change).and_yield expect { subject.execute %w[generate --watch] } .to output_approval('cli/generate/watch') @@ -241,8 +241,8 @@ context 'when ConfigurationError is raised during watch' do it 'shows the error gracefully and continues to watch' do - allow(Filewatcher).to receive(:new).and_return(watcher_double) - allow(watcher_double).to receive(:watch) do |&block| + allow(Watch).to receive(:new).and_return(watch_double) + allow(watch_double).to receive(:on_change) do |&block| bashly_config['invalid_option'] = 'error this' File.write bashly_config_path, bashly_config.to_yaml block.call diff --git a/spec/bashly/commands/render_spec.rb b/spec/bashly/commands/render_spec.rb index 77d34b1d..8e736654 100644 --- a/spec/bashly/commands/render_spec.rb +++ b/spec/bashly/commands/render_spec.rb @@ -68,11 +68,11 @@ describe 'SOURCE TARGET --watch' do let(:bashly_config_path) { "#{source_dir}/bashly.yml" } let(:bashly_config) { YAML.load_file bashly_config_path } - let(:watcher_double) { instance_double Filewatcher, watch: nil } + let(:watch_double) { instance_double Watch, on_change: nil } it 'generates immediately and on change' do - allow(Filewatcher).to receive(:new).and_return(watcher_double) - allow(watcher_double).to receive(:watch).and_yield + allow(Watch).to receive(:new).and_return(watch_double) + allow(watch_double).to receive(:on_change).and_yield expect(subject).to receive(:render).twice diff --git a/spec/bashly/commands/shell_spec.rb b/spec/bashly/commands/shell_spec.rb index bbf16335..feee4a93 100644 --- a/spec/bashly/commands/shell_spec.rb +++ b/spec/bashly/commands/shell_spec.rb @@ -20,7 +20,7 @@ describe 'in-terminal commands' do before do ENV['BASHLY_SHELL'] = nil - allow(Readline).to receive(:readline).and_return(*input) + allow(Reline).to receive(:readline).and_return(*input) end context 'with exit command' do diff --git a/spec/bashly/concerns/completions_command_spec.rb b/spec/bashly/concerns/completions_command_spec.rb index c5553faf..4b3cefa3 100644 --- a/spec/bashly/concerns/completions_command_spec.rb +++ b/spec/bashly/concerns/completions_command_spec.rb @@ -1,8 +1,7 @@ describe Script::Command do - fixtures = load_fixture 'script/commands' - subject { described_class.new fixtures[fixture] } + let(:fixtures) { load_fixture('script/commands') } let(:fixture) { :completions_simple } describe '#completion_data' do diff --git a/spec/bashly/concerns/completions_flag_spec.rb b/spec/bashly/concerns/completions_flag_spec.rb index 7a19f153..dcb71b9d 100644 --- a/spec/bashly/concerns/completions_flag_spec.rb +++ b/spec/bashly/concerns/completions_flag_spec.rb @@ -1,8 +1,7 @@ describe Script::Flag do - fixtures = load_fixture 'script/flags' - subject { described_class.new fixtures[fixture] } + let(:fixtures) { load_fixture 'script/flags' } let(:fixture) { :basic_flag } let(:command) { 'some command' } diff --git a/spec/bashly/config_validator_spec.rb b/spec/bashly/config_validator_spec.rb index 9d091fea..b4f77fd7 100644 --- a/spec/bashly/config_validator_spec.rb +++ b/spec/bashly/config_validator_spec.rb @@ -1,13 +1,14 @@ describe ConfigValidator do - fixtures = load_fixture 'script/validations' + fixtures = load_fixture('script/validations') describe '#validate' do fixtures.each do |fixture, options| - validator = described_class.new options - context "with :#{fixture}" do + let(:validator) { described_class.new(options) } + it 'raises an error' do - expect { validator.validate }.to raise_approval("validations/#{fixture}") + expect { validator.validate } + .to raise_approval("validations/#{fixture}") end end end diff --git a/spec/bashly/integration/examples_spec.rb b/spec/bashly/integration/examples_spec.rb index 6285fc1a..c3a851bf 100644 --- a/spec/bashly/integration/examples_spec.rb +++ b/spec/bashly/integration/examples_spec.rb @@ -5,41 +5,44 @@ # # To only test examples containing a certain string in their path, run: # EXAMPLE=yaml bundle exec run spec examples - describe 'generated bash scripts', :slow do # Make sure all examples are generated with strict mode before { ENV['BASHLY_STRICT'] = 'yes' } after { ENV['BASHLY_STRICT'] = nil } # Test public examples from the examples folder... - examples = Dir['examples/*'].select { |f| File.directory? f } + examples = Dir['examples/*'].select { |f| File.directory?(f) } # ...as well as internal examples, not suitable for public view fixtures = Dir['spec/fixtures/workspaces/*'].select do |f| - File.directory? f and File.exist? "#{f}/test.sh" + File.directory?(f) && File.exist?("#{f}/test.sh") end test_cases = fixtures + examples # Allow up to a certain string distance from the approval text in CI - leeway = ENV['CI'] ? 40 : 0 + let(:leeway) { ENV['CI'] ? 40 : 0 } # For certain examples, allow some exceptions (replacements) since they # are too volatile (e.g. line number changes) - exceptions = { - 'examples/stacktrace' => [/download:\d+/, 'download:'], - 'examples/render-mandoc' => [/Version 0.1.0.*download\(1\)/, '