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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## HEAD

- Added: `match:` and `match_first:` kwargs to `file.append` for locating the insertion point by content instead of line number. `match:` raises if the target is not unique; `match_first:` uses the first occurrence.
- Added: `file.before` command with the same `match:` / `match_first:` kwargs. Inserts content before the matched line.
- Changed: Shell commands now run under `bash -eo pipefail` instead of `/bin/sh`. This surfaces failures in pipelines and compound commands that were previously swallowed. See the README for details on SIGPIPE interactions.

## 6.0.0
Expand Down
87 changes: 50 additions & 37 deletions lib/rundoc/code_command/file_command/append.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
# frozen_string_literal: true

class Rundoc::CodeCommand::FileCommand
class AppendArgs
attr_reader :filename
class InsertArgs
attr_reader :filename, :match, :match_first, :line_number

def initialize(filename)
@filename = filename
def initialize(filename, match: nil, match_first: nil)
@filename, line = filename.split("#")
@line_number = Integer(line) if line
@match = match
@match_first = match_first

if @match && @match_first
raise "Cannot use both match: and match_first:"
end

if (@match || @match_first)&.empty?
raise "match value cannot be empty"
end

if match_string && @line_number
raise "Cannot use both match: and #line_number"
end
end

def match_string
@match || @match_first
end
end

Expand All @@ -17,15 +36,19 @@ class AppendRunner
attr_reader :io, :contents

def initialize(user_args:, render_command:, render_result:, io:, contents: nil)
@filename, line = user_args.filename.split("#")
@line_number = if line
Integer(line)
end
@filename = user_args.filename
@match = user_args.match
@match_first = user_args.match_first
@line_number = user_args.line_number
@io = io
@render_command = render_command
@contents = contents.dup if contents && !contents.empty?
end

def match_string
@match || @match_first
end

def render_command?
@render_command
end
Expand All @@ -37,7 +60,9 @@ def to_md(env)
raise "Must call append in its own code section"
end

env[:before] << if @line_number
env[:before] << if match_string
"In file `#{filename}`, after `#{match_string}`, add:"
elsif @line_number
"In file `#{filename}`, on line #{@line_number} add:"
else
"At the end of `#{filename}` add:"
Expand All @@ -46,44 +71,32 @@ def to_md(env)
nil
end

def last_char_of(string)
string[-1, 1]
end

def ends_in_newline?(string)
last_char_of(string) == "\n"
end

def concat_with_newline(str1, str2)
result = +""
result << str1
result << "\n" unless ends_in_newline?(result)
result << "\n" unless str1.end_with?("\n")
result << str2
result << "\n" unless ends_in_newline?(result)
result << "\n" unless str2.end_with?("\n")
result
end

def insert_contents_into_at_line(doc)
lines = doc.lines
raise "Expected #{filename} to have at least #{@line_number} but only has #{lines.count}" if lines.count < @line_number
result = []
lines.each_with_index do |line, index|
line_number = index.next
if line_number == @line_number
result << contents
result << "\n" unless ends_in_newline?(contents)
end
result << line
end
result.flatten.join("")
end

def call(env = {})
mkdir_p
doc = File.read(filename)
if @line_number
if match_string
line = Rundoc::CodeCommand::FileUtil.resolve_match_line(
doc: doc, match_str: match_string, filename: filename, unique: !!@match
)
line += 1
io.puts "Inserting at line #{line} after #{match_string.inspect} in '#{filename}' with: #{contents.inspect}"
doc = Rundoc::CodeCommand::FileUtil.insert_contents_at_line(
doc: doc, line_number: line, contents: contents, filename: filename
)
elsif @line_number
io.puts "Writing to: '#{filename}' line #{@line_number} with: #{contents.inspect}"
doc = insert_contents_into_at_line(doc)
doc = Rundoc::CodeCommand::FileUtil.insert_contents_at_line(
doc: doc, line_number: @line_number, contents: contents, filename: filename
)
else
io.puts "Appending to file: '#{filename}' with: #{contents.inspect}"
doc = concat_with_newline(doc, contents)
Expand All @@ -95,4 +108,4 @@ def call(env = {})
end
end

Rundoc.register_code_command(keyword: :"file.append", args_klass: Rundoc::CodeCommand::FileCommand::AppendArgs, runner_klass: Rundoc::CodeCommand::FileCommand::AppendRunner)
Rundoc.register_code_command(keyword: :"file.append", args_klass: Rundoc::CodeCommand::FileCommand::InsertArgs, runner_klass: Rundoc::CodeCommand::FileCommand::AppendRunner)
71 changes: 71 additions & 0 deletions lib/rundoc/code_command/file_command/before.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

class Rundoc::CodeCommand::FileCommand
class BeforeRunner
NEWLINE = Rundoc::CodeCommand::WriteRunner::NEWLINE

include Rundoc::CodeCommand::FileUtil

attr_reader :io, :contents

def initialize(user_args:, render_command:, render_result:, io:, contents: nil)
@filename = user_args.filename
@match = user_args.match
@match_first = user_args.match_first
@line_number = user_args.line_number
@io = io
@render_command = render_command
@contents = contents.dup if contents && !contents.empty?
end

def match_string
@match || @match_first
end

def render_command?
@render_command
end

def to_md(env)
return unless render_command?

if env[:commands].any? { |c| c[:visibility].not_hidden? }
raise "Must call file.before in its own code section"
end

env[:before] << if match_string
"In file `#{filename}`, before `#{match_string}`, add:"
elsif @line_number
"In file `#{filename}`, before line #{@line_number}, add:"
else
"At the beginning of `#{filename}` add:"
end
env[:before] << NEWLINE
nil
end

def call(env = {})
mkdir_p
doc = File.read(filename)
if match_string
line = Rundoc::CodeCommand::FileUtil.resolve_match_line(
doc: doc, match_str: match_string, filename: filename, unique: !!@match
)
io.puts "Inserting at line #{line} before #{match_string.inspect} in '#{filename}' with: #{contents.inspect}"
elsif @line_number
line = @line_number
io.puts "Writing to: '#{filename}' before line #{line} with: #{contents.inspect}"
else
line = 1
io.puts "Prepending to file: '#{filename}' with: #{contents.inspect}"
end
doc = Rundoc::CodeCommand::FileUtil.insert_contents_at_line(
doc: doc, line_number: line, contents: contents, filename: filename
)
File.write(filename, doc)
contents
end
end
end

Rundoc.register_code_command(keyword: :"file.before", args_klass: Rundoc::CodeCommand::FileCommand::InsertArgs, runner_klass: Rundoc::CodeCommand::FileCommand::BeforeRunner)
39 changes: 39 additions & 0 deletions lib/rundoc/code_command/write.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,44 @@ def mkdir_p
dir = File.expand_path("../", filename)
FileUtils.mkdir_p(dir)
end

def self.resolve_match_line(doc:, match_str:, filename:, unique:)
lines = doc.lines
matching_indices = lines.each_index.select { |i| lines[i].include?(match_str) }

if matching_indices.empty?
raise "Could not find match #{match_str.inspect} in #{filename}"
end

if unique && matching_indices.length != 1
raise "Expected 1 match for #{match_str.inspect} in #{filename} but found #{matching_indices.length}. Use match_first: if multiple matches are expected."
end

matching_indices.first + 1
end

def self.insert_contents_at_line(doc:, line_number:, contents:, filename:)
lines = doc.lines
if line_number > lines.count + 1
raise "Expected #{filename} to have at least #{line_number - 1} lines but only has #{lines.count}"
end

result = []
lines.each_with_index do |line, index|
if index.next == line_number
result << contents
result << "\n" unless contents.end_with?("\n")
end
result << line
end

if line_number == lines.count + 1
result << contents
result << "\n" unless contents.end_with?("\n")
end

result.join("")
end
end

class WriteArgs
Expand Down Expand Up @@ -75,4 +113,5 @@ def call(env = {})
Rundoc.register_code_command(keyword: :"file.write", args_klass: Rundoc::CodeCommand::WriteArgs, runner_klass: Rundoc::CodeCommand::WriteRunner)

require "rundoc/code_command/file_command/append"
require "rundoc/code_command/file_command/before"
require "rundoc/code_command/file_command/remove"
93 changes: 88 additions & 5 deletions test/rundoc/code_commands/append_file_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def test_appends_to_a_file
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::AppendArgs.new(file),
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new(file),
contents: "bar"
)
cc.call
Expand All @@ -25,7 +25,7 @@ def test_appends_to_a_file
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::AppendArgs.new(file),
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new(file),
contents: "baz"
)
cc.call
Expand Down Expand Up @@ -53,7 +53,7 @@ def test_appends_to_a_file_at_line_number
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::AppendArgs.new("#{file}##{line}"),
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new("#{file}##{line}"),
contents: "gem 'pg'"
)
cc.call
Expand All @@ -73,7 +73,7 @@ def test_globs_filenames
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::AppendArgs.new("file-*.txt"),
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new("file-*.txt"),
contents: "some text"
)
cc.call
Expand All @@ -93,12 +93,95 @@ def test_glob_multiple_matches_raises
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::AppendArgs.new("file-*.txt"),
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new("file-*.txt"),
contents: "some text"
)
cc.call
end
end
end
end

def test_appends_to_a_file_at_match
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
file = "Gemfile"
File.write(file, "source 'https://rubygems.org'\ngem 'rails', '4.0.0'\n")

cc = Rundoc::CodeCommand::FileCommand::AppendRunner.new(
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new(file, match: "gem 'rails'"),
contents: "gem 'pg'"
)
cc.call

expected = "source 'https://rubygems.org'\ngem 'rails', '4.0.0'\ngem 'pg'\n"
assert_equal expected, File.read(file)
end
end
end

def test_appends_to_a_file_at_match_first
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
file = "app.py"
File.write(file, "import os\nimport sys\nprint('hello')\n")

cc = Rundoc::CodeCommand::FileCommand::AppendRunner.new(
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new(file, match_first: "import"),
contents: "import json"
)
cc.call

expected = "import os\nimport json\nimport sys\nprint('hello')\n"
assert_equal expected, File.read(file)
end
end
end

def test_appends_to_a_file_at_match_raises_when_not_found
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
file = "Gemfile"
File.write(file, "source 'https://rubygems.org'\n")

error = assert_raises(RuntimeError) do
cc = Rundoc::CodeCommand::FileCommand::AppendRunner.new(
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new(file, match: "gem 'rails'"),
contents: "gem 'pg'"
)
cc.call
end
assert_match(/Could not find match/, error.message)
end
end
end

def test_appends_raises_when_match_and_line_number_both_used
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
file = "foo.rb"
File.write(file, "line one\nline two\n")

error = assert_raises(RuntimeError) do
Rundoc::CodeCommand::FileCommand::AppendRunner.new(
render_command: false,
render_result: false,
io: StringIO.new,
user_args: Rundoc::CodeCommand::FileCommand::InsertArgs.new("#{file}#2", match: "line two"),
contents: "inserted"
)
end
assert_match(/Cannot use both match: and #line_number/, error.message)
end
end
end
end
Loading
Loading