Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
thmsmlr committed Jul 26, 2024
0 parents commit 1daa537
Show file tree
Hide file tree
Showing 8 changed files with 449 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "demo.exs"]
]
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# The directory Mix will write compiled artifacts to.
/_build/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where third-party dependencies like ExDoc output generated docs.
/doc/

# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Ignore package tarball (built via "mix hex.build").
livescript-*.tar

# Temporary files, for example, from tests.
/tmp/
29 changes: 29 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "mix_task",
"name": "mix (Default task)",
"request": "launch",
"projectDir": "${workspaceRoot}"
},
{
"type": "mix_task",
"name": "mix test",
"request": "launch",
"task": "test",
"taskArgs": [
"--trace"
],
"startApps": true,
"projectDir": "${workspaceRoot}",
"requireFiles": [
"test/**/test_helper.exs",
"test/**/*_test.exs"
]
}
]
}
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Livescript

Love Livebook, but want to write .exs files?
Livescript runs your .exs files in an iex shell, and reruns them on change.
The key is that it doesn't rerun the whole file, only the parts that changed.
Just like Livebook stale tracking, though admittedly simplified.

## Why?

This is incredibly useful for a fast feedback loop when your scripts have heavy setup.
For instance, I use this while writing light webscrapers with [Wallaby](https://hexdocs.pm/wallaby/Wallaby.html).
It takes a few seconds to load the browser, but with Livescript not only is the browser window left open for fast feedback when you make a change to the code, but the browser open so you can debug and poke around in the developer console.

## Sure, but why not?

The gensis for this project came from [this tweet](https://x.com/thmsmlr/status/1814354658858524944).
There is a certain feature set that I want for developing quick and dirty scripts:

- Bring your own Editor
- for AI Coding features like in [Cursor](https://www.cursor.com)
- and custom keybindings and editor integrations
- REPL like fast feedback loop
- Runnable via CRON
- Full LSP
- Standalone dependencies

Whether it's a Mix Task, an .exs file, or a Livebook, none meet all of the requirements above.
After some research, for my usecase Livescript was the easiest path.
Another viable solution to these requirements would be to build a Jupyter like integration with VSCode and make .livemd files runnable from the command line.
In fact I'd probably like that solution better because Livescript doesn't give you [Kino](https://hexdocs.pm/kino/Kino.html) notebook features.
But alas, I only wanted to spend a weekend on this project, and so Livescript is the way.

Enjoy!

## Installation

```
mix archive.install github thmsmlr/livescript
```

## Usage

```
iex -S mix livescript my_script.exs
```

## Someday maybe?

- [ ] elixir-ls [support for Mix.install](https://github.com/elixir-lsp/elixir-ls/issues/654)
- [ ] does Mix.install require special handling?
- [ ] inconvenient that you cannot [import top-level a module](https://github.com/elixir-lang/elixir/pull/10674#issuecomment-782057780) defined in the same file, would be nice to find a fix.
- [ ] Do the fancy diff tracking from Livebook instead of just rerunning all exprs after first change
- FWIW, given the lightweight nature of the scripts i've been writing, this hasn't been a big issue

## TODO
- [x] Mix archive local install
- [x] iex -S mix livescript demo.exs
- [x] Find the iex process
- [x] setup file watcher
- [x] on change do expr diff
- [x] send exprs one by one to iex evaluator
- [x] checkpoint up to the last expr that succeeded, and future diffs from there
- [x] Last expr should print inspect of the result
- [x] Gracefully handle compilation / syntax errors
- [ ] Print a spinner or something in IEx to denote that it's running
- [ ] Stacktraces should have correct line numbers (this may be trickier than I'd like...)
- [ ] checkpoint bindings, rewind to bindings to diff point
- [ ] code.purge any module definitions that were changed so we can avoid the redefinition warning
- [ ] Verify that it handles that weird edge case when there's only one expr in the file , or zero
227 changes: 227 additions & 0 deletions lib/mix/tasks/livescript.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
defmodule Mix.Tasks.Livescript do
use Mix.Task

def run([exs_path]) do
qualified_exs_path = Path.absname(exs_path) |> Path.expand()

Logger.put_module_level(Livescript, :info)
Livescript.start_link(qualified_exs_path)
end
end

defmodule Livescript do
use GenServer
require Logger

@moduledoc """
This module provides a way to live reload Elixir code in development mode.
It works by detecting changes in the file and determining which lines of code need
to be rerun to get to a final consistent state.
"""

@poll_interval 300

# Client API

def start_link(file_path) do
GenServer.start_link(__MODULE__, %{file_path: file_path})
end

# Server callbacks

@impl true
def init(%{file_path: file_path} = state) do
IO.puts(IO.ANSI.yellow() <> "Watching #{file_path} for changes..." <> IO.ANSI.reset())
schedule_poll()
state = Map.merge(state, %{executed_exprs: [], last_modified: nil})
{:ok, state}
end

@impl true
def handle_info(:poll, %{file_path: file_path, last_modified: nil} = state) do
# first poll, run the code completely
with {:ok, %{mtime: mtime}} <- File.stat(file_path),
{:ok, current_code} <- File.read(file_path),
{:ok, {_, _, current_exprs}} <- Code.string_to_quoted(current_code) do
executed_exprs = execute_code(current_exprs)
{:noreply, %{state | executed_exprs: executed_exprs, last_modified: mtime}}
else
{:error, reason} ->
IO.puts(
IO.ANSI.red() <>
"Failed to read & run file #{file_path}: #{inspect(reason)}" <> IO.ANSI.reset()
)

{:noreply, %{state | last_modified: File.stat!(file_path).mtime}}
end
after
schedule_poll()
end

@impl true
def handle_info(:poll, %{file_path: file_path} = state) do
case File.stat(file_path) do
{:ok, %{mtime: mtime}} ->
if mtime > state.last_modified do
[_, current_time] =
NaiveDateTime.from_erl!(:erlang.localtime())
|> NaiveDateTime.to_string()
|> String.split(" ")

basename = Path.basename(file_path)

IO.puts(
IO.ANSI.yellow() <>
"[#{current_time}] #{basename} has been modified" <> IO.ANSI.reset()
)

next_code = File.read!(file_path)

case Code.string_to_quoted(next_code) do
{:ok, {_, _, next_exprs}} ->
{common_exprs, _rest_exprs, rest_next_exprs} =
split_at_diff(state.executed_exprs, next_exprs)

Logger.debug("Common exprs: #{Macro.to_string(common_exprs)}")
Logger.debug("Next exprs: #{Macro.to_string(rest_next_exprs)}")

executed_next_exprs = execute_code(rest_next_exprs)

{:noreply,
%{
state
| executed_exprs: common_exprs ++ executed_next_exprs,
last_modified: mtime
}}

{:error, err} ->
IO.puts(
IO.ANSI.red() <>
"Syntax error: #{inspect(err)}" <> IO.ANSI.reset()
)

{:noreply, %{state | last_modified: mtime}}
end
else
{:noreply, state}
end

{:error, _reason} ->
IO.puts(IO.ANSI.red() <> "Failed to stat file #{file_path}" <> IO.ANSI.reset())
{:noreply, state}
end
catch
e ->
IO.puts(IO.ANSI.red() <> "Error: #{inspect(e)}" <> IO.ANSI.reset())
{:noreply, state}
after
schedule_poll()
end

# Helper functions

defp execute_code(exprs) when is_list(exprs) do
{iex_evaluator, iex_server} = find_iex()

num_exprs = length(exprs)

exprs
|> Enum.with_index()
|> Enum.take_while(fn {expr, index} ->
is_last = index == num_exprs - 1

callhome_expr =
quote do
send(
:erlang.list_to_pid(unquote(:erlang.pid_to_list(self()))),
:__livescript_complete__
)
end

code = """
livescript_result__ = (#{Macro.to_string(expr)})
#{Macro.to_string(callhome_expr)}
#{if is_last, do: "livescript_result__", else: "IEx.dont_display_result()"}
"""

send(iex_evaluator, {:eval, iex_server, code, 1, ""})
wait_for_iex(iex_evaluator, iex_server, timeout: :infinity)

receive do
:__livescript_complete__ -> true
after
50 ->
false
end
end)
|> Enum.map(fn {result, _} -> result end)
end

defp schedule_poll do
Process.send_after(self(), :poll, @poll_interval)
end

@doc """
Split two lists at the first difference.
Returns a tuple with the common prefix, the rest of the first list, and the rest of the second list.
"""
def split_at_diff(first, second) do
{prefix, rest1, rest2} = do_split_at_diff(first, second, [])
{Enum.reverse(prefix), rest1, rest2}
end

defp do_split_at_diff([h | t1], [h | t2], acc), do: do_split_at_diff(t1, t2, [h | acc])
defp do_split_at_diff(rest1, rest2, acc), do: {acc, rest1, rest2}

defp wait_for_iex(iex_evaluator, iex_server, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)

# Make a request, once we get the response, we know the mailbox is free
Task.async(fn ->
send(iex_evaluator, {:fields_from_env, iex_server, nil, self(), []})

receive do
x -> x
end
end)
|> Task.await(timeout)
end

defp find_iex(opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
start_time = System.monotonic_time(:millisecond)
do_find_iex(timeout, start_time)
end

defp do_find_iex(timeout, start_time) do
:erlang.processes()
|> Enum.find_value(fn pid ->
info = Process.info(pid)

case info[:dictionary][:"$initial_call"] do
{IEx.Evaluator, _, _} ->
iex_server = info[:dictionary][:iex_server]
iex_evaluator = pid
{iex_evaluator, iex_server}

_ ->
nil
end
end)
|> case do
nil ->
current_time = System.monotonic_time(:millisecond)

if current_time - start_time < timeout do
Process.sleep(10)
do_find_iex(timeout, start_time)
else
raise "Timeout: Could not find IEx process within #{timeout} milliseconds"
end

x ->
x
end
end
end
28 changes: 28 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Livescript.MixProject do
use Mix.Project

def project do
[
app: :livescript,
version: "0.1.0",
elixir: "~> 1.15",
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
Loading

0 comments on commit 1daa537

Please sign in to comment.