Skip to content
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Install pandoc
run: rush get pandoc

- name: Install shfmt
run: rush get shfmt

# libyaml needed for Ruby's YAML library
- name: Install OS dependencies
run: sudo apt-get -y install libyaml-dev
Expand Down
2 changes: 1 addition & 1 deletion examples/render-mandoc/docs/download.1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.\" Automatically generated by Pandoc 3.2
.\"
.TH "download" "1" "July 2025" "Version 0.1.0" "Sample application"
.TH "download" "1" "August 2025" "Version 0.1.0" "Sample application"
.SH NAME
\f[B]download\f[R] \- Sample application
.SH SYNOPSIS
Expand Down
2 changes: 1 addition & 1 deletion examples/render-mandoc/docs/download.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
% download(1) Version 0.1.0 | Sample application
% Lana Lang
% July 2025
% August 2025

NAME
==================================================
Expand Down
2 changes: 1 addition & 1 deletion lib/bashly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ module Bashly
module Script
autoloads 'bashly/script', %i[
Argument Base CatchAll Command Dependency EnvironmentVariable Flag
Variable Wrapper
Formatter Variable Wrapper
]

module Introspection
Expand Down
4 changes: 2 additions & 2 deletions lib/bashly/extensions/string.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def wrap(length = 80)
end * "\n"
end

def lint
gsub(/\s+\n/m, "\n\n").lines.grep_v(/^\s*##/).join
def remove_private_comments
lines.grep_v(/^\s*##/).join
end

def remove_front_matter
Expand Down
26 changes: 18 additions & 8 deletions lib/bashly/libraries/settings/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,26 @@ partials_extension: sh
#-------------------------------------------------------------------------------

# Configure the bash options that will be added to the initialize function:
# strict: true Bash strict mode (set -euo pipefail)
# strict: false Only exit on errors (set -e)
# strict: '' Do not add any 'set' directive
# strict: <string> Add any other custom 'set' directive
# strict: true # Bash strict mode (set -euo pipefail)
# strict: false # Only exit on errors (set -e)
# strict: '' # Do not add any 'set' directive
# strict: <string> # Add any other custom 'set' directive
strict: false

# When true, the generated script will use tab indentation instead of spaces
# (every 2 leading spaces will be converted to a tab character)
tab_indent: false

# Choose a post-processor for the generated script:
# formatter: internal # Use Bashly's internal formatter (compacts newlines)
# formatter: external # Run the external command `shfmt --case-indent --indent 2`
# formatter: none # Disable formatting entirely
# formatter: <string> # Use a custom shell command to format the script.
# # The command will receive the script via stdin and
# # must output the result to stdout.
# # Example: shfmt --minify
formatter: internal


#-------------------------------------------------------------------------------
# INTERFACE OPTIONS
Expand Down Expand Up @@ -100,10 +110,10 @@ env: development

# Tweak the script output by enabling or disabling some script output.
# These options accept one of the following strings:
# - production render this feature only when env == production
# - development render this feature only when env == development
# - always render this feature in any environment
# - never do not render this feature
# - production # render this feature only when env == production
# - development # render this feature only when env == development
# - always # render this feature in any environment
# - never # do not render this feature
enable_header_comment: always
enable_bash3_bouncer: always
enable_view_markers: development
Expand Down
44 changes: 44 additions & 0 deletions lib/bashly/script/formatter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
require 'open3'
require 'shellwords'

module Bashly
module Script
class Formatter
attr_reader :script, :mode

def initialize(script, mode: nil)
@script = script
@mode = mode&.to_s || 'internal'
end

def formatted_script
case mode
when 'internal' then script.gsub(/\s+\n/m, "\n\n")
when 'external' then shfmt_result
when 'none' then script
else custom_formatter_result mode
end
end

private

def shfmt_result
custom_formatter_result %w[shfmt --case-indent --indent 2]
end

def custom_formatter_result(command)
command = Shellwords.split command if command.is_a? String

begin
output, error, status = Open3.capture3(*command, stdin_data: script)
rescue Errno::ENOENT
raise Error, "Command not found: g`#{command.first}`"
end

raise Error, "Failed running g`#{Shellwords.join command}`:\n\n#{error}" unless status.success?

output
end
end
end
end
8 changes: 7 additions & 1 deletion lib/bashly/script/wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ def base_code
[header, body]
end

result.join("\n").lint
clean_code result.join("\n")
end

def clean_code(script)
result = script.remove_private_comments
formatter = Formatter.new result, mode: Settings.formatter
formatter.formatted_script
end

def header
Expand Down
5 changes: 5 additions & 0 deletions lib/bashly/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class << self
:enable_inspect_args,
:enable_sourcing,
:enable_view_markers,
:formatter,
:function_names,
:lib_dir,
:partials_extension,
Expand Down Expand Up @@ -86,6 +87,10 @@ def env=(value)
@env = value&.to_sym
end

def formatter
@formatter ||= get :formatter
end

def full_lib_dir
"#{source_dir}/#{lib_dir}"
end
Expand Down
19 changes: 19 additions & 0 deletions schemas/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,25 @@
],
"default": "development"
},
"formatter": {
"title": "formatter",
"description": "Choose how to post-process the generated script\nhttps://bashly.dev/usage/settings/#formatter",
"anyOf": [
{
"type": "string",
"enum": [
"internal",
"external",
"none"
]
},
{
"type": "string",
"minLength": 1
}
],
"default": "internal"
},
"partials_extension": {
"title": "partials extension",
"description": "The extension to use when reading/writing partial script snippets\nhttps://bashly.dev/usage/settings/#partials_extension",
Expand Down
2 changes: 1 addition & 1 deletion spec/approvals/examples/render-mandoc
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ ISSUE TRACKER
AUTHORS
Lana Lang.

Version 0.1.0 July 2025 download(1)
Version 0.1.0 August 2025 download(1)
4 changes: 2 additions & 2 deletions spec/approvals/examples/stacktrace
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ Examples:

Stack trace:
from ./download:15 in `root_command`
from ./download:260 in `run`
from ./download:266 in `main`
from ./download:259 in `run`
from ./download:265 in `main`
1 change: 1 addition & 0 deletions spec/approvals/formatter/error-failure
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#<Bashly::Error:"Failed running g`shfmt --diff`:\n\n">
1 change: 1 addition & 0 deletions spec/approvals/formatter/error-not-found
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#<Bashly::Error: Command not found: g`my_formatter`>
14 changes: 14 additions & 0 deletions spec/approvals/formatter/internal
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash

funk() {
cat <<EOF
start multiline heredoc test

end multiline heredoc test
EOF

echo "unnecessary multiline test"

echo "unnecessary multiline test complete"
echo "rogue indentation"
}
16 changes: 16 additions & 0 deletions spec/approvals/formatter/shfmt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

funk() {
cat <<EOF
start multiline heredoc test



end multiline heredoc test
EOF

echo "unnecessary multiline test"

echo "unnecessary multiline test complete"
echo "rogue indentation"
}
17 changes: 17 additions & 0 deletions spec/approvals/formatter/shfmt-custom
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash

funk()
{
cat <<EOF
start multiline heredoc test



end multiline heredoc test
EOF

echo "unnecessary multiline test"

echo "unnecessary multiline test complete"
echo "rogue indentation"
}
18 changes: 4 additions & 14 deletions spec/bashly/extensions/string_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,11 @@
end
end

describe '#lint' do
context 'with a string that contains multiple consecutive newlines' do
subject { "one\n two\n \n three\n \n \nfour\n\n\n\n" }
describe '#remove_private_comments' do
subject { "this is important\n## SECRET\n ## ANOTHER SECRET\n also important\n" }

it 'replaces two or more newlines with two newlines' do
expect(subject.lint).to eq "one\n two\n\n three\n\nfour\n\n"
end
end

context 'with a string that contains double-hash comments' do
subject { "this is important\n## SECRET\n ## ANOTHER SECRET\n also important\n" }

it 'removes these comments' do
expect(subject.lint).to eq "this is important\n also important\n"
end
it 'removes these comments' do
expect(subject.remove_private_comments).to eq "this is important\n also important\n"
end
end

Expand Down
53 changes: 53 additions & 0 deletions spec/bashly/script/formatter_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
describe Script::Formatter do
subject { described_class.new script, mode: mode }

let(:script) { File.read "spec/fixtures/formatter/#{script_id}.sh" }
let(:script_id) { :simple }
let(:mode) { nil }

describe '#formatted_script' do
it 'formats the script using the internal formatter' do
expect(subject.formatted_script).to match_approval 'formatter/internal'
end

context 'with mode: none' do
let(:mode) { 'none' }

it 'returns the original script' do
expect(subject.formatted_script).to eq script
end
end

context 'with mode: external' do
let(:mode) { 'external' }

it 'uses shfmt to format the script' do
expect(subject.formatted_script).to match_approval 'formatter/shfmt'
end
end

context 'with mode: shfmt (string)' do
let(:mode) { 'shfmt --func-next-line' }

it 'uses the given command shfmt to format the script' do
expect(subject.formatted_script).to match_approval 'formatter/shfmt-custom'
end
end

context 'when the external command does not exist' do
let(:mode) { 'my_formatter' }

it 'raises a Bashly::Error' do
expect { subject.formatted_script }.to raise_approval 'formatter/error-not-found'
end
end

context 'when the external command fails' do
let(:mode) { 'shfmt --diff' }

it 'raises a Bashly::Error' do
expect { subject.formatted_script }.to raise_approval 'formatter/error-failure'
end
end
end
end
18 changes: 18 additions & 0 deletions spec/fixtures/formatter/simple.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash

funk() {
cat <<EOF
start multiline heredoc test



end multiline heredoc test
EOF

echo "unnecessary multiline test"



echo "unnecessary multiline test complete"
echo "rogue indentation"
}
14 changes: 14 additions & 0 deletions support/schema/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,20 @@ properties:
type: string
enum: *feature_toggles
default: development
formatter:
title: formatter
description: |-
Choose how to post-process the generated script
https://bashly.dev/usage/settings/#formatter
anyOf:
- type: string
enum:
- internal
- external
- none
- type: string
minLength: 1
default: internal
partials_extension:
title: partials extension
description: |-
Expand Down