Current section
Files
Jump to
Current section
Files
lib/buf_edit.ex
defmodule BufEdit do
@moduledoc """
Programmable buffer editor, similar in concept to [ed](https://en.wikipedia.org/wiki/Ed_(text_editor)).
`BufEdit` reads a file into memory and provides a flexible API for editing it and writing
it back to a file.
Consider the following mix.exs file:
```elixir
defmodule MyApp.MixProject do
use Mix.Project
def project do
[
app: :my_app,
version: "0.1.0",
elixir: "~> 1.6",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
]
end
end
```
We'll use `BufEdit` to load the file and jump to a line matching a regular
expression:
```elixir
iex> buf = BufEdit.load("mix.exs")
iex> buf = BufEdit.search(buf, ~r/defp deps/, :down)
%BufEdit{
col: 1,
filename: "test/fixtures/mix.exs",
lines: ["defmodule MyApp.MixProject do", " use Mix.Project", ""],
line_num: 22,
status: :ok
}
```
(The `lines` key value above has been abbreviated in this example)
If the line is found, the returned `%BufEdit{}` has a `:status` key set to `:ok`,
and the `:line_num` key is set to the new line number.
Now, let's say we want to remove the two comment lines from the dependencies
list and add a new dependency:
```elixir
iex> buf = BufEdit.search(buf, ~r/^\s*#/, :down)
%BufEdit{
col: 1,
filename: "test/fixtures/mix.exs",
lines: ["defmodule MyApp.MixProject do", " use Mix.Project", ""],
line_num: 24,
status: :ok
}
```
Now, we're at line 24. If you want to see line 24, use `line/1`:
```elixir
iex> BufEdit.line(buf)
" # {:dep_from_hexpm, \\"~> 0.3.0\\"},"
```
Yep, that's the line we're looking for.
The next step is to delete the two comments:
```elixir
iex> buf = BufEdit.delete_lines(buf, 2)
%BufEdit{
col: 1,
filename: "test/fixtures/mix.exs",
lines: ["defmodule MyApp.MixProject do", " use Mix.Project", ""],
line_num: 24,
status: :ok
}
iex> BufEdit.line(buf)
" ]"
```
Now that the lines are deleted, we're ready to add the new dependency:
```elixir
iex> buf = BufEdit.insert_line(buf, " {:buffer, \\"~> 0.1.0\\"}")
iex> BufEdit.dump(buf) |> IO.puts()
defmodule MyApp.MixProject do
use Mix.Project
def project do
[
app: :my_app,
version: "0.1.0",
elixir: "~> 1.6",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:buffer, "~> 0.1.0"}
]
end
end
```
Our new dependency is added! Now it's time to write the file, then we're done:
```elixir
iex> BufEdit.save(buf)
```
"""
@type t :: %__MODULE__{
filename: String.t(),
lines: [String.t()],
line_num: integer(),
col: integer(),
status: atom()
}
defstruct filename: nil,
lines: [],
line_num: 1,
col: 1,
status: :ok
@doc "Load the file into the buffer"
@spec load(filename :: String.t()) :: t()
def load(filename) do
contents = File.read!(filename)
lines = String.split(contents, ~r/\r\n|\n/)
%__MODULE__{
filename: filename,
lines: lines
}
end
@doc "Dump the BufEdit to a string"
@spec dump(t()) :: String.t()
def dump(buf) do
Enum.join(buf.lines, "\n")
end
@doc "Save the BufEdit to a file specified by the `:filename` value."
@spec save(t()) :: :ok | no_return()
def save(buf) do
File.write!(buf.filename, dump(buf))
end
@doc "Get the current line."
@spec line(t()) :: String.t()
def line(buf) do
Enum.at(buf.lines, buf.line_num - 1)
end
@doc "Get a list of lines starting from the current line number."
@spec lines(buf :: t(), length :: integer()) :: [String.t()]
def lines(buf, length) do
0..(length - 1)
|> Enum.map(fn i ->
Enum.at(buf.lines, buf.line_num - 1 + i)
end)
end
@doc "Search for a line using a regular expression."
@spec search(buf :: t(), pattern :: Regex.t(), direction :: :down | :up) :: t()
def search(buf, pattern, direction \\ :down) do
line_with_index = matching_line_with_index(buf, pattern, direction)
case line_with_index do
{_line, index} ->
buf = move_to(buf, index + 1, buf.col)
%{buf | status: :ok}
_ ->
%{buf | status: :not_found}
end
end
@doc "Insert a line at the current line number."
@spec insert_line(buf :: t(), line :: String.t()) :: t()
def insert_line(%{status: :ok} = buf, line) do
lines = List.insert_at(buf.lines, buf.line_num - 1, line)
buf
|> set(:lines, lines)
|> move_relative(1, buf.col)
|> set(:status, :ok)
end
# Skip operation if :status != :ok
def insert_line(buf, _line) do
buf
end
@doc "Insert multiple lines at the current line number."
@spec insert_lines(buf :: t(), lines :: [String.t()]) :: t()
def insert_lines(buf, lines) do
new_buf =
lines
|> Enum.reduce(buf, fn line, buf ->
insert_line(buf, line)
end)
%{new_buf | status: :ok}
end
@doc "Move to the given line number and column"
@spec move_to(buf :: t(), line_num :: integer(), col :: integer()) :: t()
def move_to(buf, line_num, col) do
%{buf | line_num: line_num, col: col, status: :ok}
end
@doc "Move to a line offset from the current line."
@spec move_relative(buf :: t(), line_num_offset :: integer(), col_offset :: integer()) :: t()
def move_relative(buf, line_num_offset, col_offset) do
move_to(buf, buf.line_num + line_num_offset, buf.col + col_offset)
end
@doc "Move to the last line in the file."
@spec move_to_end(buf :: t()) :: t()
def move_to_end(buf) do
count = length(buf.lines)
move_to(buf, count, buf.col)
end
@doc "Delete a number of lines from the current line number"
@spec delete_lines(buf :: t(), count :: integer()) :: t()
def delete_lines(buf, count) do
1..count
|> Enum.reduce(buf, fn _n, buf ->
delete_line(buf)
end)
end
@doc "Delete the current line"
@spec delete_line(buf :: t()) :: t()
def delete_line(%{status: :ok} = buf) do
lines = List.delete_at(buf.lines, buf.line_num - 1)
%{buf | lines: lines, status: :ok}
end
# Skip operation if :status != :ok
def delete_line(buf) do
buf
end
@doc "Replace a sub string within the current line matching pattern."
@spec replace_in_line(buf :: t(), search :: String.t(), replace :: String.t()) :: t()
def replace_in_line(buf, search, replace) do
replace_line(buf, fn _buf, line ->
String.replace(line, search, replace)
end)
end
@doc """
Replace the current line with the output of a function.
Example:
Commenting out a line of Elixir:
```
iex> BufEdit.replace_line(buf, fn _buf, line -> "# \#{line}" end)
```
"""
@spec replace_line(t(), (t(), line :: String.t() -> String.t())) :: t()
def replace_line(%{status: :ok} = buf, fun) do
line = line(buf)
buf = delete_line(buf)
new_line = fun.(buf, line)
insert_line(buf, new_line)
end
# Skip operation if :status != :ok
def replace_line(buf, _fun) do
buf
end
## PRIVATE FUNCTIONS
defp set(buf, key, value) do
struct(buf, %{key => value})
end
defp matching_line_with_index(buf, pattern, direction) do
lines =
case direction do
:down -> buf.lines
:up -> Enum.reverse(buf.lines)
end
line_count = length(buf.lines)
lines
|> Enum.with_index()
|> Enum.map(fn {line, index} ->
case direction do
:down -> {line, index}
:up -> {line, line_count - index - 1}
end
end)
|> Enum.filter(fn {_line, index} ->
at_line_num = index + 1
case direction do
:down -> at_line_num >= buf.line_num
:up -> at_line_num <= buf.line_num
end
end)
|> Enum.find(fn {line, _index} ->
String.match?(line, pattern)
end)
end
end