-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 1daa537
Showing
8 changed files
with
449 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
] | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.