├── .formatter.exs
├── .gitignore
├── .vscode
└── launch.json
├── README.md
├── assets
└── livescript-video.png
├── lib
├── app.ex
├── livescript.ex
├── livescript
│ ├── broadcast.ex
│ ├── executor.ex
│ └── tcp.ex
└── mix
│ └── tasks
│ └── livescript.ex
├── mix.exs
├── test
├── livescript_test.exs
└── test_helper.exs
└── vscode-extension
├── .gitignore
├── .vscode
└── launch.json
├── LICENSE
├── README.md
├── assets
├── icon.png
├── livescript-vscode-launch.png
└── livescript-vscode.png
├── extension.js
└── package.json
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "demo.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | livescript-*.tar
24 |
25 | # Temporary files, for example, from tests.
26 | /tmp/
27 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "mix_task",
9 | "name": "mix (Default task)",
10 | "request": "launch",
11 | "projectDir": "${workspaceRoot}"
12 | },
13 | {
14 | "type": "mix_task",
15 | "name": "mix test",
16 | "request": "launch",
17 | "task": "test",
18 | "taskArgs": [
19 | "--trace"
20 | ],
21 | "debugAutoInterpretAllModules": false,
22 | "debugInterpretModulesPatterns": ["Livescript.*"],
23 | "startApps": true,
24 | "projectDir": "${workspaceRoot}",
25 | "requireFiles": [
26 | "test/**/test_helper.exs",
27 | "test/**/*_test.exs"
28 | ]
29 | }
30 | ]
31 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Livescript
2 |
3 | Love Livebook, but want to write .exs files?
4 | Livescript runs your .exs files in an iex shell, and reruns them on change.
5 | The key is that it doesn't rerun the whole file, only the parts that changed.
6 | Just like Livebook stale tracking, though admittedly simplified.
7 |
8 |
9 |
14 |
15 |
16 | ## Why?
17 |
18 | This is incredibly useful for a fast feedback loop when your scripts have heavy setup.
19 | For instance, I use this while writing light webscrapers with [Wallaby](https://hexdocs.pm/wallaby/Wallaby.html).
20 | 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.
21 |
22 | ## Sure, but why not?
23 |
24 | The gensis for this project came from [this tweet](https://x.com/thmsmlr/status/1814354658858524944).
25 | There is a certain feature set that I want for developing quick and dirty scripts:
26 |
27 | - Bring your own Editor
28 | - for AI Coding features like in [Cursor](https://www.cursor.com)
29 | - and custom keybindings and editor integrations
30 | - REPL like fast feedback loop
31 | - Runnable via CRON
32 | - Full LSP
33 | - Standalone dependencies
34 |
35 | Whether it's a Mix Task, an .exs file, or a Livebook, none meet all of the requirements above.
36 | After some research, for my usecase Livescript was the easiest path.
37 | 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.
38 | In fact I'd probably like that solution better because Livescript doesn't give you [Kino](https://hexdocs.pm/kino/Kino.html) notebook features.
39 | But alas, I only wanted to spend a weekend on this project, and so Livescript is the way.
40 |
41 | Enjoy!
42 |
43 | ## Installation
44 |
45 | ```bash
46 | mix archive.install github thmsmlr/livescript
47 | ```
48 |
49 | ## Usage
50 |
51 | ```bash
52 | iex -S mix livescript my_script.exs
53 | ```
54 |
55 | There are special variables that get set in the environment when running through Livescript.
56 |
57 | - `__LIVESCRIPT__` - Set to "1" when running through Livescript.
58 | - `__LIVESCRIPT_FILE__` - The path of the file being executed, equivalent to `__ENV__.file` which isn't set when running through IEX.
59 |
60 | You can check for the existence of these variables to customize what your script does when running through livescript, versus just regular.
61 | For instance, a somewhat common thing you'll want to do is to do file base operations relative to the directory of the script, not the current working directory.
62 |
63 | ```elixir
64 | :ok =
65 | System.get_env("__LIVESCRIPT_FILE__", __ENV__.file)
66 | |> Path.dirname()
67 | |> File.cd()
68 |
69 | is_livescript = !!System.get_env("__LIVESCRIPT__")
70 | ```
71 |
72 | ## Someday maybe?
73 |
74 | - [ ] elixir-ls [support for Mix.install](https://github.com/elixir-lsp/elixir-ls/issues/654)
75 | - [ ] does Mix.install require special handling?
76 | - [ ] 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.
77 | - [ ] `iex -S mix livescript run my_script.exs` which runs line by line via IEX, then exits.
78 | - This gets around the aforementioned issue with importing modules defined in the same file.
79 | - [ ] Do the fancy diff tracking from Livebook instead of just rerunning all exprs after first change
80 | - FWIW, given the lightweight nature of the scripts i've been writing, this hasn't been a big issue
81 |
82 | ## TODO
83 | - [x] Mix archive local install
84 | - [x] iex -S mix livescript demo.exs
85 | - [x] Find the iex process
86 | - [x] setup file watcher
87 | - [x] on change do expr diff
88 | - [x] send exprs one by one to iex evaluator
89 | - [x] checkpoint up to the last expr that succeeded, and future diffs from there
90 | - [x] Last expr should print inspect of the result
91 | - [x] Gracefully handle compilation / syntax errors
92 | - [ ] Print a spinner or something in IEx to denote that it's running
93 | - [x] Stacktraces should have correct line numbers (this may be trickier than I'd like...)
94 | - [ ] checkpoint bindings, rewind to bindings to diff point
95 | - [ ] code.purge any module definitions that were changed so we can avoid the redefinition warning
96 | - [ ] Verify that it handles that weird edge case when there's only one expr in the file , or zero
97 |
--------------------------------------------------------------------------------
/assets/livescript-video.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thmsmlr/livescript/06b6655003c92b8586c57f875eac809addbbe2ad/assets/livescript-video.png
--------------------------------------------------------------------------------
/lib/app.ex:
--------------------------------------------------------------------------------
1 | defmodule Livescript.App do
2 | use Application
3 |
4 | def start(_type, [script_path]) do
5 | qualified_script_path = Path.absname(script_path) |> Path.expand()
6 |
7 | children = [
8 | {Task.Supervisor, name: Livescript.TaskSupervisor},
9 | {Livescript.Broadcast, []},
10 | {Livescript.Executor, []},
11 | {Livescript, qualified_script_path},
12 | {Task, fn -> Livescript.TCP.server() end}
13 | ]
14 |
15 | opts = [strategy: :one_for_one, name: Livescript.Supervisor]
16 | {:ok, _} = Supervisor.start_link(children, opts)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/livescript.ex:
--------------------------------------------------------------------------------
1 | defmodule Livescript.Expression do
2 | defstruct [:quoted, :code, :line_start, :line_end]
3 | end
4 |
5 | defmodule Livescript do
6 | use GenServer
7 | require Logger
8 |
9 | alias Livescript.Expression
10 |
11 | @moduledoc """
12 | This module provides a way to live reload Elixir code in development mode.
13 |
14 | It works by detecting changes in the file and determining which lines of code need
15 | to be rerun to get to a final consistent state.
16 | """
17 |
18 | @poll_interval 300
19 |
20 | # Client API
21 |
22 | def start_link(file_path) do
23 | GenServer.start_link(__MODULE__, %{file_path: file_path}, name: __MODULE__)
24 | end
25 |
26 | @doc """
27 | Run the expressions after the given line number.
28 |
29 | Note: if you are running this from the IEx shell in a livescript session
30 | there will be a deadlock. Instead, execute it from a Task as shown,
31 |
32 | Task.async(fn -> Livescript.run_after_cursor(27) end)
33 | """
34 | def run_after_cursor(code, line_number) when is_integer(line_number) and is_binary(code) do
35 | GenServer.call(__MODULE__, {:run_after_cursor, code, line_number}, :infinity)
36 | end
37 |
38 | def run_at_cursor(code, line_number, line_end)
39 | when is_integer(line_number) and is_integer(line_end) and is_binary(code) do
40 | GenServer.call(__MODULE__, {:run_at_cursor, code, line_number, line_end}, :infinity)
41 | end
42 |
43 | def execute_code(code) when is_binary(code) do
44 | line_number = 1
45 | line_end = (String.split(code, "\n") |> length()) - 1
46 | run_at_cursor(code, line_number, line_end)
47 | end
48 |
49 | def get_state do
50 | GenServer.call(__MODULE__, :get_state)
51 | end
52 |
53 | # Server callbacks
54 | @impl true
55 | def init(%{file_path: file_path} = state) do
56 | IO.puts(IO.ANSI.yellow() <> "Watching #{file_path} for changes..." <> IO.ANSI.reset())
57 | send(self(), :poll)
58 |
59 | state =
60 | Map.merge(state, %{
61 | pending_execution: [],
62 | current_expr_running: nil,
63 | executed_exprs: [],
64 | last_modified: nil
65 | })
66 |
67 | {:ok, state}
68 | end
69 |
70 | @impl true
71 | def handle_call(:get_state, _from, state) do
72 | {:reply, state, state}
73 | end
74 |
75 | @impl true
76 | def handle_call({:run_after_cursor, code, line_number}, _from, %{file_path: _file_path} = state) do
77 | with {:ok, exprs} <- parse_code(code) do
78 | exprs_after =
79 | exprs
80 | |> Enum.filter(fn %Expression{line_start: min_line} ->
81 | min_line >= line_number
82 | end)
83 |
84 | IO.puts(IO.ANSI.yellow() <> "Running code after line #{line_number}:" <> IO.ANSI.reset())
85 |
86 | state = enqueue_execution(exprs_after, state, ignore: true)
87 |
88 | {:reply, :ok, state}
89 | else
90 | error ->
91 | {:reply, error, state}
92 | end
93 | end
94 |
95 | @impl true
96 | def handle_call(
97 | {:run_at_cursor, code, line_start, line_end},
98 | _from,
99 | %{file_path: _file_path} = state
100 | ) do
101 | with {:ok, exprs} <- parse_code(code) do
102 | exprs_at =
103 | exprs
104 | |> Enum.filter(fn %Expression{line_start: expr_start, line_end: expr_end} ->
105 | # Expression overlaps with selection if:
106 | # - Expression starts within selection, OR
107 | # - Expression ends within selection, OR
108 | # - Expression completely contains selection
109 | (expr_start >= line_start && expr_start <= line_end) ||
110 | (expr_end >= line_start && expr_end <= line_end) ||
111 | (expr_start <= line_start && expr_end >= line_end)
112 | end)
113 |
114 | range_str =
115 | if line_start == line_end,
116 | do: "line #{line_start}",
117 | else: "lines #{line_start}-#{line_end}"
118 |
119 | IO.puts(IO.ANSI.yellow() <> "Running code at #{range_str}:" <> IO.ANSI.reset())
120 |
121 | state = enqueue_execution(exprs_at, state, ignore: true)
122 |
123 | {:reply, :ok, state}
124 | else
125 | error ->
126 | {:reply, error, state}
127 | end
128 | end
129 |
130 | @impl true
131 | def handle_info({:file_changed, next_code, mtime}, %{file_path: file_path} = state) do
132 | with {:parse_code, {:ok, next_exprs}} <- {:parse_code, parse_code(next_code)} do
133 | # Print modification message (except for first run)
134 | if state.last_modified != nil do
135 | [_, current_time] =
136 | NaiveDateTime.from_erl!(:erlang.localtime())
137 | |> NaiveDateTime.to_string()
138 | |> String.split(" ")
139 |
140 | basename = Path.basename(file_path)
141 |
142 | IO.puts(
143 | IO.ANSI.yellow() <>
144 | "[#{current_time}] #{basename} has been modified" <> IO.ANSI.reset()
145 | )
146 | end
147 |
148 | # For first run, execute preamble
149 | state =
150 | if state.last_modified == nil do
151 | enqueue_execution(preamble_code(file_path), state, ignore: true)
152 | else
153 | state
154 | end
155 |
156 | {common_exprs, _rest_exprs, _rest_next} =
157 | split_at_diff(
158 | Enum.map(state.executed_exprs, & &1.quoted),
159 | Enum.map(next_exprs, & &1.quoted)
160 | )
161 |
162 | common_exprs = Enum.take(next_exprs, length(common_exprs))
163 | rest_next_exprs = Enum.drop(next_exprs, length(common_exprs))
164 | state = enqueue_execution(rest_next_exprs, state, ignore: false)
165 |
166 | {:noreply, %{state | executed_exprs: common_exprs, last_modified: mtime}}
167 | else
168 | {:parse_code, {:error, reason}} ->
169 | IO.puts(
170 | IO.ANSI.red() <>
171 | "Failed to parse file: #{inspect(reason)}" <> IO.ANSI.reset()
172 | )
173 |
174 | {:noreply, %{state | last_modified: mtime}}
175 | end
176 | end
177 |
178 | @impl true
179 | def handle_info(:poll, %{file_path: file_path} = state) do
180 | mtime = File.stat!(file_path).mtime
181 |
182 | if state.last_modified == nil || mtime > state.last_modified do
183 | send(self(), {:file_changed, File.read!(file_path), mtime})
184 | end
185 |
186 | {:noreply, %{state | last_modified: mtime}}
187 | after
188 | Process.send_after(self(), :poll, @poll_interval)
189 | end
190 |
191 | @impl true
192 | def handle_info(:process_queue, %{current_expr_running: x} = state) when not is_nil(x),
193 | do: {:noreply, state}
194 |
195 | @impl true
196 | def handle_info(:process_queue, %{pending_execution: []} = state), do: {:noreply, state}
197 |
198 | @impl true
199 | def handle_info(:process_queue, %{pending_execution: [{expr, opts} | rest]} = state) do
200 | Task.Supervisor.async_nolink(Livescript.TaskSupervisor, fn ->
201 | broadcast_event(:executing, %{exprs: [expr]})
202 | {ignore, opts} = Keyword.pop(opts, :ignore, false)
203 | executed_exprs = Livescript.Executor.execute([expr], opts)
204 |
205 | if ignore do
206 | {:done_executing, []}
207 | else
208 | {:done_executing, executed_exprs}
209 | end
210 | end)
211 |
212 | {:noreply, %{state | pending_execution: rest, current_expr_running: expr}}
213 | end
214 |
215 | @impl true
216 | def handle_info({_ref, {:done_executing, exprs}}, state) do
217 | broadcast_event(:done_executing, %{
218 | executed_exprs: exprs,
219 | last_modified: state.last_modified
220 | })
221 |
222 | {:noreply,
223 | %{state | executed_exprs: state.executed_exprs ++ exprs, current_expr_running: nil}}
224 | after
225 | send(self(), :process_queue)
226 | end
227 |
228 | def handle_info({:DOWN, _ref, _, _, :normal}, state) do
229 | {:noreply, state}
230 | end
231 |
232 | defp enqueue_execution(exprs, state, opts) when is_list(exprs) do
233 | Keyword.validate!(opts, ignore: :boolean)
234 | ignore = Keyword.get(opts, :ignore, false)
235 |
236 | exprs =
237 | exprs
238 | |> Enum.with_index()
239 | |> Enum.map(fn {expr, index} ->
240 | {expr, ignore: ignore, ignore_last_expression: index != length(exprs) - 1}
241 | end)
242 |
243 | %{state | pending_execution: state.pending_execution ++ exprs}
244 | after
245 | send(self(), :process_queue)
246 | end
247 |
248 | # Helper functions
249 |
250 | def preamble_code(file_path) do
251 | code =
252 | quote do
253 | System.put_env("__LIVESCRIPT__", "1")
254 | System.put_env("__LIVESCRIPT_FILE__", unquote(file_path))
255 | System.argv(unquote(current_argv()))
256 | IEx.dont_display_result()
257 | end
258 | |> Macro.to_string()
259 |
260 | {:ok, exprs} = parse_code(code)
261 | exprs
262 | end
263 |
264 | defp current_argv() do
265 | case System.argv() do
266 | ["livescript", "run", _path | argv] -> argv
267 | ["livescript", _path | argv] -> argv
268 | argv -> argv
269 | end
270 | end
271 |
272 | @doc """
273 | Split two lists at the first difference.
274 | Returns a tuple with the common prefix, the rest of the first list, and the rest of the second list.
275 | """
276 | def split_at_diff(first, second) do
277 | {prefix, rest1, rest2} = do_split_at_diff(first, second, [])
278 | {Enum.reverse(prefix), rest1, rest2}
279 | end
280 |
281 | defp do_split_at_diff([h | t1], [h | t2], acc), do: do_split_at_diff(t1, t2, [h | acc])
282 | defp do_split_at_diff(rest1, rest2, acc), do: {acc, rest1, rest2}
283 |
284 | @doc """
285 | Parse the code and return a list of expressions with the line range.
286 |
287 | It parses the code twice to get the precise line range (see [string_to_quoted/2](https://hexdocs.pm/elixir/1.17.2/Code.html#quoted_to_algebra/2-formatting-considerations)).
288 | """
289 | def parse_code(code) do
290 | parse_opts = [
291 | literal_encoder: &{:ok, {:__block__, &2, [&1]}},
292 | token_metadata: true,
293 | unescape: false
294 | ]
295 |
296 | with {:ok, quoted} <- string_to_quoted_expressions(code),
297 | {:ok, precise_quoted} <- string_to_quoted_expressions(code, parse_opts) do
298 | exprs =
299 | Enum.zip(quoted, precise_quoted)
300 | |> Enum.map(fn {quoted, precise_quoted} ->
301 | {line_start, line_end} = line_range(precise_quoted)
302 | amount_of_lines = max(line_end - line_start + 1, 0)
303 |
304 | code =
305 | code
306 | |> String.split("\n")
307 | |> Enum.slice(line_start - 1, amount_of_lines)
308 | |> Enum.join("\n")
309 |
310 | %Livescript.Expression{
311 | quoted: quoted,
312 | code: code,
313 | line_start: line_start,
314 | line_end: line_end
315 | }
316 | end)
317 |
318 | {:ok, exprs}
319 | end
320 | end
321 |
322 | defp broadcast_event(type, payload) do
323 | Livescript.Broadcast.broadcast(%{
324 | type: type,
325 | payload: payload,
326 | timestamp: :os.system_time(:millisecond)
327 | })
328 | end
329 |
330 | defp string_to_quoted_expressions(code, opts \\ []) do
331 | case Code.string_to_quoted(code, opts) do
332 | {:ok, {:__block__, _, quoted}} -> {:ok, quoted}
333 | {:ok, quoted} -> {:ok, [quoted]}
334 | {:error, reason} -> {:error, reason}
335 | end
336 | end
337 |
338 | defp line_range({_, opts, nil}) do
339 | line_start = opts[:line] || :infinity
340 | line_end = opts[:end_of_expression][:line] || opts[:last][:line] || opts[:closing][:line] || 0
341 | {line_start, line_end}
342 | end
343 |
344 | defp line_range({_, opts, children}) do
345 | line_start = opts[:line] || :infinity
346 | line_end = opts[:end_of_expression][:line] || opts[:last][:line] || opts[:closing][:line] || 0
347 |
348 | {child_min, child_max} =
349 | children
350 | |> Enum.map(&line_range/1)
351 | |> Enum.unzip()
352 |
353 | {
354 | Enum.min([line_start | child_min]),
355 | Enum.max([line_end | child_max])
356 | }
357 | end
358 |
359 | defp line_range(_), do: {:infinity, 0}
360 | end
361 |
--------------------------------------------------------------------------------
/lib/livescript/broadcast.ex:
--------------------------------------------------------------------------------
1 | defmodule Livescript.Broadcast do
2 | use GenServer
3 | require Logger
4 |
5 | # Client API
6 |
7 | def start_link(_opts) do
8 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
9 | end
10 |
11 | def subscribe(subscriber_pid) do
12 | GenServer.call(__MODULE__, {:subscribe, subscriber_pid})
13 | end
14 |
15 | def unsubscribe(subscriber_pid) do
16 | GenServer.call(__MODULE__, {:unsubscribe, subscriber_pid})
17 | end
18 |
19 | def broadcast(event) do
20 | GenServer.cast(__MODULE__, {:broadcast, event})
21 | end
22 |
23 | # Server Callbacks
24 |
25 | @impl true
26 | def init(:ok) do
27 | {:ok, %{subscribers: MapSet.new()}}
28 | end
29 |
30 | @impl true
31 | def handle_call({:subscribe, pid}, _from, state) do
32 | Process.monitor(pid)
33 | new_subscribers = MapSet.put(state.subscribers, pid)
34 | {:reply, :ok, %{state | subscribers: new_subscribers}}
35 | end
36 |
37 | @impl true
38 | def handle_call({:unsubscribe, pid}, _from, state) do
39 | new_subscribers = MapSet.delete(state.subscribers, pid)
40 | {:reply, :ok, %{state | subscribers: new_subscribers}}
41 | end
42 |
43 | @impl true
44 | def handle_cast({:broadcast, event}, state) do
45 | Enum.each(state.subscribers, fn pid ->
46 | send(pid, {:broadcast, event})
47 | end)
48 | {:noreply, state}
49 | end
50 |
51 | @impl true
52 | def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
53 | new_subscribers = MapSet.delete(state.subscribers, pid)
54 | {:noreply, %{state | subscribers: new_subscribers}}
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/livescript/executor.ex:
--------------------------------------------------------------------------------
1 | defmodule Livescript.Executor do
2 | use GenServer
3 | require Logger
4 |
5 | # Client API
6 |
7 | def start_link(_opts) do
8 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
9 | end
10 |
11 | def execute(exprs, opts \\ []) do
12 | GenServer.call(__MODULE__, {:execute, exprs, opts}, :infinity)
13 | end
14 |
15 | # Server Callbacks
16 |
17 | @impl true
18 | def init(state) do
19 | {:ok, state}
20 | end
21 |
22 | @impl true
23 | def handle_call({:execute, exprs, opts}, _from, state) do
24 | result = execute_code(exprs, opts)
25 | {:reply, result, state}
26 | end
27 |
28 | # Helper functions moved from Livescript module
29 |
30 | def execute_code(exprs, opts) do
31 | ignore_last_expression = Keyword.get(opts, :ignore_last_expression, false)
32 | num_exprs = length(exprs)
33 |
34 | exprs
35 | |> Enum.with_index()
36 | |> Enum.take_while(fn {%Livescript.Expression{} = expr, index} ->
37 | is_last = index == num_exprs - 1
38 | start_line = expr.line_start
39 |
40 | do_execute_code("livescript_result__ = (#{expr.code})",
41 | start_line: start_line,
42 | async: true,
43 | call_home_with: :success,
44 | print_result:
45 | if(is_last and not ignore_last_expression,
46 | do: "livescript_result__",
47 | else: "IEx.dont_display_result()"
48 | )
49 | )
50 |
51 | do_execute_code("", async: true, call_home_with: :complete)
52 |
53 | # If the status is :success, we need to wait for the :complete message
54 | # to keep the mailbox clean. Otherwise, there was some kind of error
55 | was_successful =
56 | receive do
57 | {:__livescript__, :success} ->
58 | receive do: ({:__livescript__, :complete} -> true)
59 |
60 | {:__livescript__, :complete} ->
61 | false
62 | end
63 |
64 | was_successful
65 | end)
66 | |> Enum.map(fn {expr, _} -> expr end)
67 | end
68 |
69 | defp do_execute_code(code, opts)
70 |
71 | defp do_execute_code(code, opts) when is_binary(code) do
72 | {iex_evaluator, iex_server} = find_iex()
73 | print_result = Keyword.get(opts, :print_result, "IEx.dont_display_result()")
74 | call_home_with = Keyword.get(opts, :call_home_with, :__livescript_complete__)
75 | start_line = Keyword.get(opts, :start_line, 1)
76 | async = Keyword.get(opts, :async, false)
77 |
78 | call_home_expr =
79 | case call_home_with do
80 | nil -> nil
81 | expr -> Macro.to_string(call_home_macro(expr))
82 | end
83 |
84 | code = """
85 | #{code}
86 | #{call_home_expr}
87 | #{print_result}
88 | """
89 |
90 | send(iex_evaluator, {:eval, iex_server, code, start_line, ""})
91 |
92 | if not async and is_atom(call_home_with) do
93 | receive do
94 | {:__livescript__, ^call_home_with} -> true
95 | end
96 | end
97 | end
98 |
99 | defp do_execute_code(exprs, opts) do
100 | do_execute_code(Macro.to_string(exprs), opts)
101 | end
102 |
103 | defp find_iex(opts \\ []) do
104 | timeout = Keyword.get(opts, :timeout, 5000)
105 | start_time = System.monotonic_time(:millisecond)
106 | do_find_iex(timeout, start_time)
107 | end
108 |
109 | defp do_find_iex(timeout, start_time) do
110 | :erlang.processes()
111 | |> Enum.find_value(fn pid ->
112 | info = Process.info(pid)
113 |
114 | case info[:dictionary][:"$initial_call"] do
115 | {IEx.Evaluator, _, _} ->
116 | iex_server = info[:dictionary][:iex_server]
117 | iex_evaluator = pid
118 | {iex_evaluator, iex_server}
119 |
120 | _ ->
121 | nil
122 | end
123 | end)
124 | |> case do
125 | nil ->
126 | current_time = System.monotonic_time(:millisecond)
127 |
128 | if current_time - start_time < timeout do
129 | Process.sleep(10)
130 | do_find_iex(timeout, start_time)
131 | else
132 | raise "Timeout: Could not find IEx process within #{timeout} milliseconds"
133 | end
134 |
135 | x ->
136 | x
137 | end
138 | end
139 |
140 | def call_home_macro(expr) do
141 | quote do
142 | send(
143 | :erlang.list_to_pid(unquote(:erlang.pid_to_list(self()))),
144 | {:__livescript__, unquote(expr)}
145 | )
146 | end
147 | end
148 | end
149 |
--------------------------------------------------------------------------------
/lib/livescript/tcp.ex:
--------------------------------------------------------------------------------
1 | defmodule Livescript.TCP do
2 | require Logger
3 |
4 | # 15 seconds in milliseconds
5 | @heartbeat_timeout 15_000
6 |
7 | def server() do
8 | file_path = :sys.get_state(Livescript).file_path
9 | port = find_available_port(13137..13237)
10 | port_file = get_port_file_path(file_path)
11 |
12 | # Write port to temp file
13 | File.mkdir_p!(Path.dirname(port_file))
14 | File.write!(port_file, "#{port}")
15 |
16 | Process.register(self(), Livescript.TCP)
17 |
18 | {:ok, socket} =
19 | :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true])
20 |
21 | IO.puts(IO.ANSI.yellow() <> "Listening for commands on port #{port}" <> IO.ANSI.reset())
22 |
23 | accept_connections(socket)
24 | end
25 |
26 | # Helper to find an available port
27 | defp find_available_port(port_range) do
28 | Enum.find(port_range, fn port ->
29 | case :gen_tcp.listen(port, [:binary]) do
30 | {:ok, socket} ->
31 | :gen_tcp.close(socket)
32 | true
33 |
34 | {:error, _} ->
35 | false
36 | end
37 | end) || raise "No available ports in range #{inspect(port_range)}"
38 | end
39 |
40 | # Generate unique temp file path for port
41 | defp get_port_file_path(file_path) do
42 | hash = :crypto.hash(:md5, file_path) |> Base.encode16(case: :lower)
43 | Path.join(System.tmp_dir!(), "livescript_#{hash}.port")
44 | end
45 |
46 | defp accept_connections(socket) do
47 | {:ok, client} = :gen_tcp.accept(socket)
48 |
49 | {:ok, pid} =
50 | Task.Supervisor.start_child(Livescript.TaskSupervisor, fn ->
51 | handle_persistent_connection(client)
52 | end)
53 |
54 | :gen_tcp.controlling_process(client, pid)
55 | accept_connections(socket)
56 | end
57 |
58 | defp handle_persistent_connection(socket) do
59 | Logger.info("Establishing persistent connection")
60 |
61 | # Switch to active mode for persistent connections
62 | :ok = :gen_tcp.send(socket, encode_response(%{type: "connection_established"}))
63 | :ok = :inet.setopts(socket, active: true)
64 |
65 | # Start heartbeat monitor
66 | main_pid = self()
67 | monitor_pid = spawn_link(fn -> monitor_heartbeat(socket, main_pid) end)
68 |
69 | Livescript.Broadcast.subscribe(main_pid)
70 |
71 | # Handle incoming messages in active mode
72 | persistent_connection_loop(socket, monitor_pid)
73 | end
74 |
75 | defp persistent_connection_loop(socket, monitor_pid, acc \\ "") do
76 | receive do
77 | {:tcp, ^socket, data} ->
78 | new_acc = acc <> data
79 |
80 | if String.ends_with?(new_acc, "\n") do
81 | Task.Supervisor.async_nolink(Livescript.TaskSupervisor, fn ->
82 | handle_persistent_message(socket, new_acc, monitor_pid)
83 | end)
84 |
85 | persistent_connection_loop(socket, monitor_pid)
86 | else
87 | persistent_connection_loop(socket, monitor_pid, new_acc)
88 | end
89 |
90 | {:tcp_closed, ^socket} ->
91 | persistent_connection_loop(socket, monitor_pid)
92 |
93 | {:tcp_closed, ^socket} ->
94 | Logger.info("Persistent connection closed by client")
95 | Process.exit(monitor_pid, :normal)
96 | :ok
97 |
98 | {:tcp_error, ^socket, reason} ->
99 | Logger.error("Persistent connection error: #{inspect(reason)}")
100 | Process.exit(monitor_pid, :normal)
101 | :gen_tcp.close(socket)
102 | :ok
103 |
104 | {:heartbeat_timeout} ->
105 | Logger.warning("Heartbeat timeout - closing connection")
106 | Process.exit(monitor_pid, :normal)
107 | :gen_tcp.close(socket)
108 | :ok
109 |
110 | {:broadcast, event} ->
111 | :gen_tcp.send(socket, encode_response(%{type: event.type, timestamp: event.timestamp}))
112 | persistent_connection_loop(socket, monitor_pid)
113 | end
114 | end
115 |
116 | defp handle_persistent_message(socket, data, monitor_pid) do
117 | with {:ok, decoded} <- try_json_decode(String.trim(data)) do
118 | case decoded do
119 | %{"command" => "heartbeat"} ->
120 | send(monitor_pid, {:heartbeat_received})
121 | :ok
122 |
123 | %{"ref" => ref} = command ->
124 | with {:ok, result} <- handle_command(command) do
125 | :gen_tcp.send(socket, encode_response(%{success: true, result: result, ref: ref}))
126 | :ok
127 | else
128 | {:error, error} ->
129 | :gen_tcp.send(socket, encode_response(%{success: false, error: error, ref: ref}))
130 | :ok
131 | end
132 |
133 | _ ->
134 | :ok
135 | end
136 | else
137 | {:error, error} ->
138 | # Include ref in error response if it was in the request
139 | error_response =
140 | case try_json_decode(String.trim(data)) do
141 | {:ok, %{"ref" => ref}} ->
142 | %{success: false, error: error, ref: ref}
143 |
144 | _ ->
145 | %{success: false, error: error}
146 | end
147 |
148 | :gen_tcp.send(socket, encode_response(error_response))
149 | :ok
150 | end
151 | end
152 |
153 | defp monitor_heartbeat(socket, main_pid) do
154 | receive do
155 | {:heartbeat_received} ->
156 | monitor_heartbeat(socket, main_pid)
157 | after
158 | @heartbeat_timeout ->
159 | send(main_pid, {:heartbeat_timeout})
160 | end
161 | end
162 |
163 | defp encode_response(data) do
164 | case try_json_encode(data) do
165 | {:ok, encoded} -> encoded <> "\n"
166 | {:error, _} -> "{\"error\": \"encoding_failed\"}\n"
167 | end
168 | end
169 |
170 | defp try_json_decode(data) do
171 | try do
172 | {:ok, data |> :json.decode()}
173 | rescue
174 | error -> {:error, "Unable to decode JSON: #{inspect(error)} on data: #{inspect(data)}"}
175 | end
176 | end
177 |
178 | defp try_json_encode(data) do
179 | try do
180 | {:ok, data |> :json.encode() |> :erlang.iolist_to_binary()}
181 | rescue
182 | error -> {:error, "Unable to encode JSON: #{inspect(error)} on data: #{inspect(data)}"}
183 | end
184 | end
185 |
186 | defp handle_command(%{"command" => "run_after_cursor", "code" => code, "line" => line}) do
187 | case Livescript.run_after_cursor(code, line) do
188 | :ok -> {:ok, true}
189 | error -> error
190 | end
191 | end
192 |
193 | defp handle_command(%{
194 | "command" => "run_at_cursor",
195 | "code" => code,
196 | "line" => line,
197 | "line_end" => line_end
198 | }) do
199 | case Livescript.run_at_cursor(code, line, line_end) do
200 | :ok -> {:ok, true}
201 | error -> error
202 | end
203 | end
204 |
205 | defp handle_command(%{"command" => "verify_connection", "filepath" => filepath}) do
206 | current_file = :sys.get_state(Livescript) |> Map.get(:file_path)
207 |
208 | {:ok,
209 | %{
210 | "connected" => Path.expand(filepath) == Path.expand(current_file),
211 | "current_file" => current_file
212 | }}
213 | end
214 |
215 | defp handle_command(%{"command" => "parse_code", "code" => code, "mode" => mode}) do
216 | with {:parse, {:ok, exprs}} <- {:parse, Livescript.parse_code(code)},
217 | %{
218 | executed_exprs: executed_exprs,
219 | current_expr_running: current_expr_running,
220 | pending_execution: pending_execution
221 | } <- Livescript.get_state() do
222 | exprs =
223 | exprs
224 | |> Enum.map(fn expr ->
225 | status =
226 | cond do
227 | current_expr_running != nil and expr.quoted == current_expr_running.quoted ->
228 | "executing"
229 |
230 | Enum.any?(pending_execution, fn {pending_expr, _} ->
231 | pending_expr.quoted == expr.quoted
232 | end) ->
233 | "pending"
234 |
235 | Enum.any?(executed_exprs, fn executed_expr ->
236 | executed_expr.quoted == expr.quoted
237 | end) ->
238 | "executed"
239 |
240 | true ->
241 | "new"
242 | end
243 |
244 | %{
245 | expr: expr.code,
246 | line_start: expr.line_start,
247 | line_end: expr.line_end,
248 | status: status
249 | }
250 | end)
251 |
252 | case mode do
253 | "block" ->
254 | merged_exprs =
255 | exprs
256 | |> Enum.sort_by(& &1.line_start)
257 | |> Enum.reduce([], fn expr, acc ->
258 | case acc do
259 | [prev | rest] when prev.line_end + 1 == expr.line_start ->
260 | merged = %{
261 | expr: prev.expr <> "\n" <> expr.expr,
262 | line_start: prev.line_start,
263 | line_end: expr.line_end,
264 | status:
265 | case {prev.status, expr.status} do
266 | {_, "executing"} -> "executing"
267 | {"executing", _} -> "executing"
268 | {_, "pending"} -> "pending"
269 | _ -> prev.status
270 | end
271 | }
272 |
273 | [merged | rest]
274 |
275 | _ ->
276 | [expr | acc]
277 | end
278 | end)
279 | |> Enum.reverse()
280 |
281 | {:ok, merged_exprs}
282 |
283 | "expression" ->
284 | {:ok, exprs}
285 |
286 | _ ->
287 | {:error, %{type: "unknown_mode", details: "`#{inspect(mode)}` mode not recognized"}}
288 | end
289 | else
290 | {:parse, {:error, error}} ->
291 | {:error,
292 | %{
293 | type: "parse_error",
294 | details: inspect(error)
295 | }}
296 | end
297 | end
298 |
299 | defp handle_command(%{"command" => "ping"}) do
300 | {:ok, %{type: "pong", timestamp: :os.system_time(:millisecond)}}
301 | end
302 |
303 | defp handle_command(command) do
304 | Logger.info("Received unknown command: #{inspect(command)}")
305 | {:error, %{type: "unknown_command", details: "Command not recognized"}}
306 | end
307 | end
308 |
--------------------------------------------------------------------------------
/lib/mix/tasks/livescript.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Livescript do
2 | use Mix.Task
3 |
4 | def run(["run", exs_path | _]) do
5 | qualified_exs_path = Path.absname(exs_path) |> Path.expand()
6 | Logger.put_module_level(Livescript, :info)
7 | code = File.read!(qualified_exs_path)
8 |
9 | Task.async(fn ->
10 | children = [
11 | {Livescript.Executor, []}
12 | ]
13 |
14 | opts = [strategy: :one_for_one, name: Livescript.Supervisor]
15 | {:ok, _} = Supervisor.start_link(children, opts)
16 |
17 | preamble_exprs = Livescript.preamble_code(qualified_exs_path)
18 | Livescript.Executor.execute(preamble_exprs)
19 | {:ok, exprs} = Livescript.parse_code(code)
20 | executed_exprs = Livescript.Executor.execute(exprs)
21 |
22 | exit_status = if executed_exprs == exprs, do: 0, else: 1
23 | hooks = :elixir_config.get_and_put(:at_exit, [])
24 |
25 | for hook <- hooks do
26 | hook.(exit_status)
27 | end
28 |
29 | System.halt(exit_status)
30 | end)
31 | end
32 |
33 | def run([exs_path | _]) do
34 | # This has to be async because otherwise Mix doesn't start the iex shell
35 | # until this function returns
36 | Task.async(fn ->
37 | qualified_exs_path = Path.absname(exs_path) |> Path.expand()
38 | Logger.put_module_level(Livescript, :info)
39 | {:ok, _} = Livescript.App.start(:normal, [qualified_exs_path])
40 | Process.sleep(:infinity)
41 | end)
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Livescript.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :livescript,
7 | version: "0.1.0",
8 | elixir: "~> 1.15",
9 | start_permanent: Mix.env() == :prod,
10 | deps: deps()
11 | ]
12 | end
13 |
14 | # Run "mix help compile.app" to learn about applications.
15 | def application do
16 | [
17 | extra_applications: [:logger],
18 | ]
19 | end
20 |
21 | # Run "mix help deps" to learn about dependencies.
22 | defp deps do
23 | [
24 | # {:dep_from_hexpm, "~> 0.3.0"},
25 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
26 | ]
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/livescript_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LivescriptTest do
2 | use ExUnit.Case
3 |
4 | describe "split_at_diff" do
5 | test "add element" do
6 | first = [1, 2, 3]
7 | second = [1, 2, 3, 4]
8 | expected = {[1, 2, 3], [], [4]}
9 | {common, rest1, rest2} = Livescript.split_at_diff(first, second)
10 | assert expected == {common, rest1, rest2}
11 | assert common ++ rest1 == first
12 | assert common ++ rest2 == second
13 | end
14 |
15 | test "change last element" do
16 | first = [1, 2, 3]
17 | second = [1, 2, 4]
18 | expected = {[1, 2], [3], [4]}
19 | {common, rest1, rest2} = Livescript.split_at_diff(first, second)
20 | assert expected == {common, rest1, rest2}
21 | assert common ++ rest1 == first
22 | assert common ++ rest2 == second
23 | end
24 |
25 | test "remove element" do
26 | first = [1, 2, 3]
27 | second = [1, 2]
28 | expected = {[1, 2], [3], []}
29 | {common, rest1, rest2} = Livescript.split_at_diff(first, second)
30 | assert expected == {common, rest1, rest2}
31 | assert common ++ rest1 == first
32 | assert common ++ rest2 == second
33 | end
34 |
35 | test "add element at the beginning" do
36 | first = [1, 2, 3]
37 | second = [4, 1, 2, 3]
38 | expected = {[], [1, 2, 3], [4, 1, 2, 3]}
39 | {common, rest1, rest2} = Livescript.split_at_diff(first, second)
40 | assert expected == {common, rest1, rest2}
41 | assert common ++ rest1 == first
42 | assert common ++ rest2 == second
43 | end
44 |
45 | test "remove element at the beginning" do
46 | first = [1, 2, 3]
47 | second = [2, 3]
48 | expected = {[], [1, 2, 3], [2, 3]}
49 | {common, rest1, rest2} = Livescript.split_at_diff(first, second)
50 | assert expected == {common, rest1, rest2}
51 | assert common ++ rest1 == first
52 | assert common ++ rest2 == second
53 | end
54 |
55 | test "change element in the middle" do
56 | first = [1, 2, 3]
57 | second = [1, 4, 3]
58 | expected = {[1], [2, 3], [4, 3]}
59 | {common, rest1, rest2} = Livescript.split_at_diff(first, second)
60 | assert expected == {common, rest1, rest2}
61 | assert common ++ rest1 == first
62 | assert common ++ rest2 == second
63 | end
64 | end
65 |
66 | describe "e2e" do
67 | setup do
68 | unique_id =
69 | :crypto.hash(:md5, :erlang.term_to_binary(:erlang.make_ref())) |> Base.encode16()
70 |
71 | script_path = Path.join(System.tmp_dir!(), "livescript_test_#{unique_id}")
72 | File.touch!(script_path)
73 |
74 | {:ok, iex_app_pid} = IEx.App.start(:normal, [])
75 | {:ok, livescript_app_pid} = Livescript.App.start(:normal, [script_path])
76 |
77 | iex_server =
78 | spawn_link(fn ->
79 | IEx.Server.run([])
80 | end)
81 |
82 | on_exit(fn ->
83 | File.rm!(script_path)
84 | Process.exit(iex_app_pid, :shutdown)
85 | Process.exit(iex_server, :shutdown)
86 | Process.exit(livescript_app_pid, :shutdown)
87 | end)
88 |
89 | %{script_path: script_path}
90 | end
91 |
92 | defp call_home_with(val, opts \\ []) do
93 | prefix = Keyword.get(opts, :prefix, :__livescript_test_return)
94 |
95 | quote do
96 | send(
97 | :erlang.list_to_pid(unquote(:erlang.pid_to_list(self()))),
98 | {unquote(prefix), unquote(val)}
99 | )
100 | end
101 | |> Macro.to_string()
102 | end
103 |
104 | defp get_return_value() do
105 | receive do
106 | {:__livescript_test_return, val} -> val
107 | after
108 | 5000 -> raise "Timeout waiting for return value"
109 | end
110 | end
111 |
112 | defp update_script(script_path, code) do
113 | %{last_modified: mtime} = Livescript.get_state()
114 | {date, {hour, minute, second}} = mtime
115 | mtime = {date, {hour, minute, second + 1}}
116 |
117 | File.write!(script_path, code)
118 | GenServer.whereis(Livescript) |> send({:file_changed, code, mtime})
119 |
120 | Livescript.execute_code("""
121 | #{call_home_with(:updated, prefix: :__livescript_script_updated)}
122 | IEx.dont_display_result()
123 | """)
124 |
125 | receive do
126 | {:__livescript_script_updated, :updated} -> :ok
127 | after
128 | 5000 -> raise "Timeout waiting for updated script to be acknowledged"
129 | end
130 | end
131 |
132 | test "can run a simple script", %{script_path: script_path} do
133 | update_script(script_path, """
134 | IO.puts("Hello World")
135 | IO.puts("This is a test")
136 | #{call_home_with(:__livescript_test_ok)}
137 | """)
138 |
139 | assert get_return_value() == :__livescript_test_ok
140 | end
141 |
142 | test "can run a script with bindings", %{script_path: script_path} do
143 | update_script(script_path, """
144 | a = 1
145 | b = 2
146 | #{call_home_with(quote do: a + b)}
147 | """)
148 |
149 | assert get_return_value() == 3
150 | end
151 |
152 | test "overwrites a binding", %{script_path: script_path} do
153 | update_script(script_path, """
154 | a = 1
155 | b = 2
156 | #{call_home_with(quote do: a + b)}
157 | """)
158 |
159 | assert get_return_value() == 3
160 |
161 | update_script(script_path, """
162 | a = 1
163 | b = 3
164 | #{call_home_with(quote do: a + b)}
165 | """)
166 |
167 | assert get_return_value() == 4
168 | end
169 |
170 | test "error doesn't crash the server", %{script_path: script_path} do
171 | update_script(script_path, """
172 | a = 1
173 | raise "This is a test error"
174 | IO.inspect(a, label: "a")
175 | """)
176 |
177 | update_script(script_path, """
178 | a = 1
179 | IO.inspect(a, label: "a")
180 | #{call_home_with(quote do: a)}
181 | """)
182 |
183 | assert get_return_value() == 1
184 | end
185 |
186 | test "can use structs", %{script_path: script_path} do
187 | update_script(script_path, """
188 | defmodule Test do
189 | defstruct a: 1
190 | end
191 |
192 | t = %Test{a: 2}
193 | #{call_home_with(quote do: t)}
194 | """)
195 |
196 | assert %{a: 2} = get_return_value()
197 | end
198 |
199 | test "doesn't rerun stale expressions", %{script_path: script_path} do
200 | update_script(script_path, """
201 | a = 1
202 | #{call_home_with(quote do: a)}
203 | """)
204 |
205 | assert get_return_value() == 1
206 |
207 | update_script(script_path, """
208 | a = 1
209 | #{call_home_with(quote do: a)}
210 | b = 2
211 | #{call_home_with(quote do: a + b)}
212 | """)
213 |
214 | # If not true, we rerun the first expression and get 1,
215 | # then the second and get 3
216 | assert get_return_value() == 3
217 | end
218 |
219 | test "is running in same session", %{script_path: script_path} do
220 | update_script(script_path, """
221 | Process.put(:livescript_test_key, :foobar)
222 | #{call_home_with(:ok)}
223 | """)
224 |
225 | assert :ok = get_return_value()
226 |
227 | update_script(script_path, """
228 | #{call_home_with(quote do: Process.get(:livescript_test_key))}
229 | """)
230 |
231 | assert :foobar = get_return_value()
232 | end
233 |
234 | test "can run empty script", %{script_path: script_path} do
235 | update_script(script_path, "")
236 | update_script(script_path, "#{call_home_with(:ok)}")
237 | assert :ok = get_return_value()
238 | end
239 |
240 | test "can handle a compile error", %{script_path: script_path} do
241 | update_script(script_path, """
242 | a = 1
243 | #{call_home_with(:ok)}
244 | """)
245 |
246 | assert :ok = get_return_value()
247 |
248 | Livescript.run_at_cursor(
249 | """
250 | a = 1
251 | b = 2
252 | for i <- foobar do
253 | i
254 | end
255 | """,
256 | 1,
257 | 3
258 | )
259 |
260 | update_script(script_path, """
261 | a = 1
262 | b = 2
263 | #{call_home_with(quote do: a + b)}
264 | """)
265 |
266 | assert 3 = get_return_value()
267 | end
268 |
269 | test "correctly handles imports", %{script_path: script_path} do
270 | update_script(script_path, """
271 | import Enum
272 | x = map([1, 2, 3], fn x -> x * 2 end)
273 | #{call_home_with(quote do: x)}
274 | """)
275 |
276 | assert [2, 4, 6] = get_return_value()
277 | end
278 | end
279 | end
280 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/vscode-extension/.gitignore:
--------------------------------------------------------------------------------
1 | *.vsix
--------------------------------------------------------------------------------
/vscode-extension/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Run Extension",
6 | "type": "extensionHost",
7 | "request": "launch",
8 | "runtimeExecutable": "${execPath}",
9 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"]
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/vscode-extension/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Thomas Miller
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/vscode-extension/README.md:
--------------------------------------------------------------------------------
1 | # VSCode LiveScript Extension
2 |
3 | A Visual Studio Code extension that enables live execution of Elixir scripts with real-time feedback.
4 | This extension allows you to run Elixir code blocks interactively within your editor, making development and experimentation more efficient.
5 | For more information about LiveScript, see the [LiveScript README](https://github.com/thmsmlr/livescript).
6 |
7 | 
8 |
9 | ## Features
10 |
11 | - **Live Code Execution**: Run Elixir code blocks directly from your editor
12 | - **Real-Time Feedback**: See results immediately in the integrated terminal
13 | - **Interactive CodeLens**: Click-to-execute buttons appear above each executable code block
14 | - **AutoRun on Save**: The LiveScript server will automatically detect changes to the file and execute only the new code (and it's dependencies)
15 |
16 | ## Requirements
17 |
18 | - Visual Studio Code v1.60.0 or higher
19 | - Elixir v1.17+ with OTP 27+
20 |
21 | ## Installation
22 |
23 | 1. Install the extension from the VSCode marketplace
24 | 2. Install the Livescript mix archive (see [LiveScript README](https://github.com/thmsmlr/livescript#installation))
25 |
26 | ## Usage
27 |
28 | ### Starting the Server
29 |
30 | 1. Open an Elixir script file (`.exs`)
31 | 2. Click the "⚡ Start LiveScript Server" CodeLens at the top of the file
32 | - Or use the command palette: `LiveScript: Start Server`
33 |
34 | 
35 |
36 | ### Executing Code
37 |
38 | There are several ways to execute code:
39 |
40 | 1. **Using CodeLens**
41 | - Click the `▷ Execute (⇧⏎)` button that appears above each code block
42 |
43 | 2. **Using Keyboard Shortcuts**
44 | - `Ctrl + Enter`: Execute the code block at the current cursor position
45 | - `Shift + Enter`: Execute the code block at the current cursor position and move to the next expression
46 | - `Ctrl + Shift + Enter`: Execute all code after the current cursor position
47 |
48 | 3. **Save the file**
49 | - The LiveScript server will automatically detect changes to the file and execute only the new code (and it's dependencies)
50 |
51 | ## Known Issues
52 |
53 | - Server must be manually restarted if the Elixir file is renamed or moved
54 |
55 | ## Contributing
56 |
57 | Contributions are welcome! Please feel free to submit a Pull Request.
58 |
59 | ## License
60 |
61 | [MIT License](LICENSE)
62 |
--------------------------------------------------------------------------------
/vscode-extension/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thmsmlr/livescript/06b6655003c92b8586c57f875eac809addbbe2ad/vscode-extension/assets/icon.png
--------------------------------------------------------------------------------
/vscode-extension/assets/livescript-vscode-launch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thmsmlr/livescript/06b6655003c92b8586c57f875eac809addbbe2ad/vscode-extension/assets/livescript-vscode-launch.png
--------------------------------------------------------------------------------
/vscode-extension/assets/livescript-vscode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thmsmlr/livescript/06b6655003c92b8586c57f875eac809addbbe2ad/vscode-extension/assets/livescript-vscode.png
--------------------------------------------------------------------------------
/vscode-extension/extension.js:
--------------------------------------------------------------------------------
1 | // The module 'vscode' contains the VS Code extensibility API
2 | // Import the module and reference it with the alias vscode in your code below
3 | const vscode = require('vscode');
4 | const net = require('net');
5 | const path = require('path');
6 | const crypto = require('crypto');
7 | const fs = require('fs');
8 | const os = require('os');
9 |
10 | class LiveScriptConnection {
11 | constructor() {
12 | this.connections = new Map(); // filepath -> {socket, reconnectTimer, connected, heartbeatInterval}
13 | this.reconnectDelay = 2000;
14 | this.messageHandlers = new Set();
15 | this.pendingResponses = new Map(); // ref -> {resolve, reject, timeout}
16 | this.debug = true; // Enable debug logging
17 | this.HEARTBEAT_INTERVAL = 5000; // Send heartbeat every 5 seconds
18 | }
19 |
20 | log(message) {
21 | if (this.debug && outputChannel) {
22 | outputChannel.appendLine(`[LiveScriptConnection] ${message}`);
23 | }
24 | }
25 |
26 | addMessageHandler(handler) {
27 | this.messageHandlers.add(handler);
28 | }
29 |
30 | removeMessageHandler(handler) {
31 | this.messageHandlers.remove(handler);
32 | }
33 |
34 | // Send a message and wait for its response
35 | async sendCommandAndWait(filepath, command, timeoutMs = 5000) {
36 | const ref = crypto.randomBytes(16).toString('hex');
37 | const commandWithRef = { ...command, ref };
38 |
39 | return new Promise((resolve, reject) => {
40 | // Set timeout
41 | const timeout = setTimeout(() => {
42 | this.pendingResponses.delete(ref);
43 | reject(new Error(`Command timed out after ${timeoutMs}ms`));
44 | }, timeoutMs);
45 |
46 | // Store the promise handlers
47 | this.pendingResponses.set(ref, { resolve, reject, timeout });
48 |
49 | // Send the command
50 | if (!this.sendMessage(filepath, commandWithRef)) {
51 | clearTimeout(timeout);
52 | this.pendingResponses.delete(ref);
53 | reject(new Error('Failed to send command - not connected'));
54 | }
55 | });
56 | }
57 |
58 | sendMessage(filepath, message) {
59 | const conn = this.connections.get(filepath);
60 | if (!conn || !conn.connected) {
61 | this.log(`Cannot send message - not connected to ${filepath}`);
62 | return false;
63 | }
64 |
65 | try {
66 | conn.socket.write(JSON.stringify(message) + '\n');
67 | return true;
68 | } catch (e) {
69 | this.log(`Error sending message to ${filepath}: ${e.message}`);
70 | return false;
71 | }
72 | }
73 |
74 | startHeartbeat(filepath) {
75 | const conn = this.connections.get(filepath);
76 | if (!conn) return;
77 |
78 | // Clear any existing heartbeat interval
79 | if (conn.heartbeatInterval) {
80 | clearInterval(conn.heartbeatInterval);
81 | }
82 |
83 | // Start new heartbeat interval
84 | conn.heartbeatInterval = setInterval(() => {
85 | if (conn.connected) {
86 | this.sendMessage(filepath, { command: 'heartbeat', timestamp: Date.now() });
87 | }
88 | }, this.HEARTBEAT_INTERVAL);
89 | }
90 |
91 | async connect(filepath) {
92 | // If we have an existing connection, clean it up first
93 | if (this.connections.has(filepath)) {
94 | const existingConn = this.connections.get(filepath);
95 | if (existingConn.reconnectTimer) {
96 | clearTimeout(existingConn.reconnectTimer);
97 | }
98 | if (existingConn.heartbeatInterval) {
99 | clearInterval(existingConn.heartbeatInterval);
100 | }
101 | if (existingConn.socket) {
102 | existingConn.socket.destroy();
103 | }
104 | this.connections.delete(filepath);
105 | }
106 |
107 | try {
108 | const port = await getServerPort(filepath);
109 | const socket = new net.Socket();
110 |
111 | this.connections.set(filepath, {
112 | socket,
113 | reconnectTimer: null,
114 | connected: false,
115 | heartbeatInterval: null
116 | });
117 |
118 | socket.on('connect', () => {
119 | this.log(`Connected to ${filepath}`);
120 | const conn = this.connections.get(filepath);
121 | conn.connected = true;
122 |
123 | // Send establish_persistent command
124 | socket.write(JSON.stringify({
125 | command: 'establish_persistent',
126 | filepath: filepath
127 | }) + '\n');
128 |
129 | // Clear any reconnect timer
130 | if (conn.reconnectTimer) {
131 | clearTimeout(conn.reconnectTimer);
132 | conn.reconnectTimer = null;
133 | }
134 |
135 | // Start heartbeat
136 | this.startHeartbeat(filepath);
137 | });
138 |
139 | socket.on('data', (data) => {
140 | try {
141 | const messages = data.toString().split('\n').filter(Boolean);
142 | messages.forEach(msg => {
143 | try {
144 | const parsed = JSON.parse(msg);
145 |
146 | // Check if this is a response to a pending command
147 | if (parsed.ref && this.pendingResponses.has(parsed.ref)) {
148 | const { resolve, reject, timeout } = this.pendingResponses.get(parsed.ref);
149 | clearTimeout(timeout);
150 | this.pendingResponses.delete(parsed.ref);
151 |
152 | if (parsed.success === false) {
153 | reject(parsed.error);
154 | } else {
155 | resolve(parsed.result);
156 | }
157 | } else {
158 | // Broadcast other messages (like heartbeats) to handlers
159 | this.broadcast(parsed, filepath);
160 | }
161 | } catch (e) {
162 | this.log(`Error parsing message: ${e.message}`);
163 | }
164 | });
165 | } catch (e) {
166 | this.log(`Error handling data: ${e.message}`);
167 | }
168 | });
169 |
170 | socket.on('error', (err) => {
171 | this.log(`Socket error for ${filepath}: ${err.message}`);
172 | this.handleDisconnect(filepath);
173 | });
174 |
175 | socket.on('close', () => {
176 | this.log(`Connection closed for ${filepath}`);
177 | this.handleDisconnect(filepath);
178 | });
179 |
180 | socket.connect(port, 'localhost');
181 | } catch (err) {
182 | this.log(`Failed to connect to ${filepath}: ${err.message}`);
183 | this.handleDisconnect(filepath);
184 | throw err; // Propagate the error
185 | }
186 | }
187 |
188 | handleDisconnect(filepath) {
189 | const conn = this.connections.get(filepath);
190 | if (!conn) return;
191 |
192 | conn.connected = false;
193 |
194 | // Clean up existing socket
195 | if (conn.socket) {
196 | conn.socket.destroy();
197 | }
198 |
199 | // Clear heartbeat interval
200 | if (conn.heartbeatInterval) {
201 | clearInterval(conn.heartbeatInterval);
202 | }
203 |
204 | // Reject any pending responses
205 | for (const [ref, { resolve, reject, timeout }] of this.pendingResponses.entries()) {
206 | clearTimeout(timeout);
207 | reject(new Error('Connection closed'));
208 | this.pendingResponses.delete(ref);
209 | }
210 |
211 | // Remove the connection entirely instead of trying to reconnect
212 | this.connections.delete(filepath);
213 | }
214 |
215 | disconnect(filepath) {
216 | const conn = this.connections.get(filepath);
217 | if (!conn) return;
218 |
219 | if (conn.reconnectTimer) {
220 | clearTimeout(conn.reconnectTimer);
221 | }
222 |
223 | if (conn.heartbeatInterval) {
224 | clearInterval(conn.heartbeatInterval);
225 | }
226 |
227 | if (conn.socket) {
228 | conn.socket.destroy();
229 | }
230 |
231 | // Reject any pending responses
232 | for (const [ref, { resolve, reject, timeout }] of this.pendingResponses.entries()) {
233 | clearTimeout(timeout);
234 | reject(new Error('Connection closed'));
235 | this.pendingResponses.delete(ref);
236 | }
237 |
238 | this.connections.delete(filepath);
239 | this.log(`Disconnected from ${filepath}`);
240 | }
241 |
242 | disconnectAll() {
243 | for (const filepath of this.connections.keys()) {
244 | this.disconnect(filepath);
245 | }
246 | }
247 |
248 | broadcast(message, filepath) {
249 | this.messageHandlers.forEach(handler => {
250 | try {
251 | handler(message, filepath);
252 | } catch (e) {
253 | this.log(`Error in message handler: ${e.message}`);
254 | }
255 | });
256 | }
257 | }
258 |
259 | // Create global instance
260 | global.livescriptConnection = new LiveScriptConnection();
261 |
262 | // Add at the top level with other constants
263 | let outputChannel;
264 |
265 | // Helper function to find the next expression after the current line
266 | function findNextExpression(expressions, currentLine) {
267 | // Sort expressions by start line
268 | const sortedExprs = [...expressions].sort((a, b) => a.line_start - b.line_start);
269 | // Find the next expression after current line
270 | return sortedExprs.find(expr => expr.line_start > currentLine);
271 | }
272 |
273 | function getPortFilePath(filepath) {
274 | const hash = crypto.createHash('md5').update(filepath).digest('hex');
275 | return path.join(os.tmpdir(), `livescript_${hash}.port`);
276 | }
277 |
278 | async function getServerPort(filepath) {
279 | const portFile = getPortFilePath(filepath);
280 | try {
281 | const port = await fs.promises.readFile(portFile, 'utf8');
282 | return parseInt(port.trim(), 10);
283 | } catch (err) {
284 | throw new Error(`Could not read LiveScript server port: ${err.message}`);
285 | }
286 | }
287 |
288 | // Define the CodeLens provider
289 | class LiveScriptCodeLensProvider {
290 | constructor(context) {
291 | this.codeLenses = [];
292 | this.expressions = [];
293 | this._onDidChangeCodeLenses = new vscode.EventEmitter();
294 | this.onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
295 | this.activeConnections = new Map(); // Track filepath -> connection status
296 | }
297 |
298 | async verifyServerConnection(filepath) {
299 | const result = await sendCommand({
300 | command: 'verify_connection',
301 | filepath: filepath
302 | });
303 | return result.connected;
304 | }
305 |
306 | // Update provideCodeLenses
307 | async provideCodeLenses(document, token) {
308 | if (!document.fileName.endsWith('.exs')) {
309 | return [];
310 | }
311 |
312 | const isConnected = await this.verifyServerConnection(document.fileName);
313 | if (!isConnected) {
314 | // Show "Start Server" lens if not connected
315 | const range = new vscode.Range(0, 0, 0, 0);
316 | return [new vscode.CodeLens(range, {
317 | title: '⚡ Start LiveScript Server',
318 | command: 'extension.livescript.start_server',
319 | arguments: [{ filepath: document.fileName }]
320 | })];
321 | }
322 |
323 | return new Promise((resolve, reject) => {
324 | const code = document.getText();
325 | const executionMode = vscode.workspace.getConfiguration('livescript').get('executionMode');
326 |
327 | // Send code to Elixir server for parsing with block mode enabled if in block mode
328 | sendCommand({
329 | command: 'parse_code',
330 | code: code,
331 | mode: executionMode,
332 | })
333 | .then(result => {
334 | this.expressions = result;
335 | this.codeLenses = [];
336 |
337 | // Create CodeLenses for each expression
338 | this.expressions.forEach(expr => {
339 | const range = new vscode.Range(
340 | expr.line_start - 1, 0, // VS Code is 0-based, Elixir is 1-based
341 | expr.line_start - 1, 0
342 | );
343 |
344 | if (expr.status === 'executing') {
345 | const title = 'Running...';
346 |
347 | this.codeLenses.push(new vscode.CodeLens(range, {
348 | title: title,
349 | }));
350 | } else if (expr.status === 'pending') {
351 | const title = 'Queued...';
352 |
353 | this.codeLenses.push(new vscode.CodeLens(range, {
354 | title: title,
355 | }));
356 | } else {
357 | const triangle = expr.status === 'executed' ? '▶️' : '▷';
358 | const title = executionMode === 'block' ?
359 | `${triangle} Execute Block (⇧⏎)` :
360 | `${triangle} Execute (⇧⏎)`;
361 |
362 | this.codeLenses.push(new vscode.CodeLens(range, {
363 | title: title,
364 | command: 'extension.livescript.run_at_cursor',
365 | arguments: [{ line: expr.line_start, line_end: expr.line_end }]
366 | }));
367 | }
368 |
369 | });
370 |
371 | resolve(this.codeLenses);
372 | })
373 | .catch(error => {
374 | if (response.error?.type === "parse_error") {
375 | resolve(this.codeLenses);
376 | return;
377 | }
378 | // For other errors, log and clear code lenses
379 | console.error("Server error:", response.error);
380 | return;
381 | });
382 | });
383 | }
384 |
385 | refresh() {
386 | this._onDidChangeCodeLenses.fire();
387 | }
388 | }
389 |
390 | async function sendCommand(command) {
391 | const filepath = command.filepath || vscode.window.activeTextEditor?.document.fileName;
392 | if (!filepath) {
393 | return { success: false, error: 'No active file' };
394 | }
395 |
396 | try {
397 | // Ensure we have a connection
398 | if (!global.livescriptConnection.connections.has(filepath)) {
399 | await global.livescriptConnection.connect(filepath);
400 | }
401 |
402 | // Send command and wait for response
403 | const result = await global.livescriptConnection.sendCommandAndWait(filepath, command);
404 | return result;
405 | } catch (err) {
406 | console.error("Command error:", err);
407 | return { success: false, error: err.message };
408 | }
409 | }
410 |
411 | // Helper to get provider instance
412 | function getCodeLensProvider() {
413 | // We'll need to store the provider instance somewhere accessible
414 | // This could be in extension state or as a module-level variable
415 | return global.livescriptProvider;
416 | }
417 |
418 | // Add this helper function at the top level
419 | function findOrCreateVerticalSplit() {
420 | const activeEditor = vscode.window.activeTextEditor;
421 | if (!activeEditor) return;
422 |
423 | // Get all visible editors
424 | const visibleEditors = vscode.window.visibleTextEditors;
425 |
426 | // Check if we already have a vertical split
427 | const hasVerticalSplit = visibleEditors.some(editor => {
428 | return editor.viewColumn !== activeEditor.viewColumn &&
429 | (editor.viewColumn === vscode.ViewColumn.One ||
430 | editor.viewColumn === vscode.ViewColumn.Two);
431 | });
432 |
433 |
434 | // If we have a vertical split, find the empty column
435 | if (hasVerticalSplit) {
436 | return activeEditor.viewColumn === vscode.ViewColumn.One ?
437 | vscode.ViewColumn.Two :
438 | vscode.ViewColumn.One;
439 | }
440 |
441 | // If no split exists, create one in the second column
442 | vscode.commands.executeCommand('workbench.action.splitEditor');
443 | return vscode.ViewColumn.Two;
444 | }
445 |
446 | // Add this helper function to use throughout your code
447 | function log(message) {
448 | if (outputChannel) {
449 | outputChannel.appendLine(message);
450 | }
451 | }
452 |
453 | /**
454 | * @param {vscode.ExtensionContext} context
455 | */
456 | function activate(context) {
457 | // Create output channel
458 | outputChannel = vscode.window.createOutputChannel('LiveScript', 'livescript-output');
459 | context.subscriptions.push(outputChannel);
460 |
461 | // Add default message handler for debugging
462 | global.livescriptConnection.addMessageHandler((message, filepath) => {
463 | outputChannel.appendLine(`[Message from ${filepath}] ${JSON.stringify(message, null, 2)}`);
464 |
465 | const provider = getCodeLensProvider();
466 | if (message.type === 'executing') {
467 | // Refresh code lenses to update decorations
468 | provider.provideCodeLenses(vscode.window.activeTextEditor.document)
469 | .then(codeLenses => {
470 | provider.refresh();
471 | });
472 | } else if (message.type === 'done_executing') {
473 | // Refresh code lenses to update decorations
474 | provider.provideCodeLenses(vscode.window.activeTextEditor.document)
475 | .then(codeLenses => {
476 | provider.refresh();
477 | });
478 | }
479 | });
480 |
481 | // Watch for file open/close events to manage connections
482 | context.subscriptions.push(
483 | vscode.workspace.onDidOpenTextDocument(doc => {
484 | if (doc.fileName.endsWith('.exs')) {
485 | global.livescriptConnection.connect(doc.fileName);
486 | }
487 | }),
488 | vscode.workspace.onDidCloseTextDocument(doc => {
489 | if (doc.fileName.endsWith('.exs')) {
490 | global.livescriptConnection.disconnect(doc.fileName);
491 | }
492 | })
493 | );
494 |
495 | // Connect to any already open .exs files
496 | vscode.workspace.textDocuments.forEach(doc => {
497 | if (doc.fileName.endsWith('.exs')) {
498 | global.livescriptConnection.connect(doc.fileName);
499 | }
500 | });
501 |
502 | // Create the CodeLens provider instance
503 | const codeLensProvider = new LiveScriptCodeLensProvider(context);
504 | global.livescriptProvider = codeLensProvider; // Store for access
505 |
506 | // Watch for configuration changes
507 | context.subscriptions.push(
508 | vscode.workspace.onDidChangeConfiguration(event => {
509 | if (event.affectsConfiguration('livescript.executionMode')) {
510 | codeLensProvider.refresh();
511 | }
512 | })
513 | );
514 |
515 | // Add command for starting the server
516 | const startServerCommand = vscode.commands.registerCommand(
517 | 'extension.livescript.start_server',
518 | async (options) => {
519 | const filepath = options.filepath;
520 |
521 | // Disconnect any existing connection first
522 | global.livescriptConnection.disconnect(filepath);
523 |
524 | // Determine the appropriate view column for the terminal
525 | const targetColumn = findOrCreateVerticalSplit();
526 |
527 | const terminal = vscode.window.createTerminal({
528 | name: 'LiveScript',
529 | location: {
530 | preserveFocus: true,
531 | viewColumn: targetColumn
532 | }
533 | });
534 |
535 | terminal.sendText(`cd ~ && iex -S mix livescript ${filepath}`);
536 | terminal.show(true);
537 |
538 | // Wait for server to start and try to connect
539 | let connected = false;
540 | for (let i = 0; i < 5; i++) { // Try 5 times
541 | await new Promise(resolve => setTimeout(resolve, 1000));
542 | const isConnected = await codeLensProvider.verifyServerConnection(filepath);
543 | if (isConnected) {
544 | connected = true;
545 | break;
546 | }
547 | }
548 |
549 | if (!connected) {
550 | vscode.window.showErrorMessage('Failed to connect to LiveScript server after multiple attempts');
551 | return;
552 | }
553 |
554 | codeLensProvider.refresh();
555 | }
556 | );
557 |
558 | // Register the run_at_cursor command
559 | let runAtCursorCommand = vscode.commands.registerCommand(
560 | 'extension.livescript.run_at_cursor',
561 | (options) => {
562 | const editor = vscode.window.activeTextEditor;
563 | if (!editor) return;
564 |
565 | const provider = getCodeLensProvider();
566 | const executionMode = vscode.workspace.getConfiguration('livescript').get('executionMode');
567 |
568 | let line = options.line;
569 | let line_end = options.line_end;
570 |
571 | if (line === undefined) {
572 | line = editor.selection.active.line + 1;
573 |
574 | if (editor.selection.isEmpty) {
575 | // todo: find the line end depending on execution mode
576 | if (executionMode === 'block') {
577 | let expr = provider.expressions.find(expr =>
578 | expr.line_start <= line && expr.line_end >= line
579 | );
580 | line = expr.line_start;
581 | line_end = expr.line_end;
582 | } else {
583 | line_end = line;
584 | }
585 | } else {
586 | line = editor.selection.start.line + 1;
587 | line_end = editor.selection.end.line + 1;
588 | }
589 | }
590 | if (!line_end) { line_end = line; }
591 |
592 | const code = editor.document.getText();
593 |
594 | // Send the appropriate command based on execution mode
595 | sendCommand({
596 | command: 'run_at_cursor',
597 | code: code,
598 | line: line,
599 | line_end: line_end,
600 | });
601 |
602 | // Move cursor to next expression if specified
603 | if (options?.moveCursorToNextExpression) {
604 | const nextExpr = findNextExpression(provider.expressions, line_end);
605 | if (nextExpr) {
606 | const newPosition = new vscode.Position(nextExpr.line_start - 1, 0);
607 | editor.selection = new vscode.Selection(newPosition, newPosition);
608 |
609 | // Reveal the new position in the middle of the viewport
610 | const range = new vscode.Range(newPosition, newPosition);
611 | editor.revealRange(range, vscode.TextEditorRevealType.InCenterIfOutsideViewport);
612 | }
613 | }
614 | }
615 | );
616 |
617 | // Add command for Ctrl+Shift+Enter keyboard shortcut (run_after_cursor)
618 | const disposableAfterCursor = vscode.commands.registerCommand(
619 | 'extension.livescript.run_after_cursor',
620 | (options) => {
621 | let line = options.line;
622 | if (line === undefined) { line = vscode.window.activeTextEditor.selection.active.line + 1; }
623 | const code = vscode.window.activeTextEditor.document.getText();
624 | sendCommand({ command: 'run_after_cursor', code: code, line: line });
625 | }
626 | );
627 |
628 | // Register the CodeLens provider with the event handler
629 | const disposableProvider = vscode.languages.registerCodeLensProvider(
630 | { scheme: 'file', language: 'elixir' },
631 | codeLensProvider
632 | );
633 |
634 | // Add selection change event listener
635 | const disposableSelectionChange = vscode.window.onDidChangeTextEditorSelection(event => {
636 | if (event.textEditor === vscode.window.activeTextEditor) {
637 | codeLensProvider.refresh();
638 | }
639 | });
640 |
641 | // Add cleanup on deactivation
642 | context.subscriptions.push({
643 | dispose: () => {
644 | global.livescriptConnection.disconnectAll();
645 | }
646 | });
647 |
648 | // Add the subscriptions to context
649 | context.subscriptions.push(
650 | disposableProvider,
651 | disposableAfterCursor,
652 | disposableSelectionChange,
653 | startServerCommand,
654 | runAtCursorCommand
655 | );
656 | }
657 |
658 | // this method is called when your extension is deactivated
659 | function deactivate() { }
660 |
661 | // eslint-disable-next-line no-undef
662 | module.exports = {
663 | activate,
664 | deactivate
665 | }
666 |
--------------------------------------------------------------------------------
/vscode-extension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "livescript-elixir",
3 | "displayName": "Livescript",
4 | "description": "Livescript extension for VS Code",
5 | "icon": "assets/icon.png",
6 | "version": "0.0.1",
7 | "publisher": "thmsmlr",
8 | "repository": "https://github.com/thmsmlr/livescript",
9 | "engines": {
10 | "vscode": "^1.74.0"
11 | },
12 | "activationEvents": [
13 | "onLanguage:elixir",
14 | "workspaceContains:**/*.exs"
15 | ],
16 | "main": "./extension.js",
17 | "contributes": {
18 | "commands": [
19 | {
20 | "command": "extension.livescript.start_server",
21 | "title": "LiveScript: Start Server"
22 | },
23 | {
24 | "command": "extension.livescript.run_at_cursor",
25 | "title": "LiveScript: Run at Cursor"
26 | },
27 | {
28 | "command": "extension.livescript.run_after_cursor",
29 | "title": "LiveScript: Run Expressions After Cursor"
30 | }
31 | ],
32 | "languages": [
33 | {
34 | "id": "elixir",
35 | "extensions": [
36 | ".ex",
37 | ".exs"
38 | ],
39 | "aliases": [
40 | "Elixir"
41 | ]
42 | }
43 | ],
44 | "keybindings": [
45 | {
46 | "command": "extension.livescript.run_at_cursor",
47 | "args": {
48 | "moveCursorToNextExpression": false
49 | },
50 | "key": "ctrl+enter",
51 | "when": "editorTextFocus && editorLangId == 'elixir'"
52 | },
53 | {
54 | "command": "extension.livescript.run_at_cursor",
55 | "args": {
56 | "moveCursorToNextExpression": true
57 | },
58 | "key": "shift+enter",
59 | "when": "editorTextFocus && editorLangId == 'elixir'"
60 | },
61 | {
62 | "command": "extension.livescript.run_after_cursor",
63 | "args": {},
64 | "key": "ctrl+shift+enter",
65 | "when": "editorTextFocus && editorLangId == 'elixir'"
66 | }
67 | ],
68 | "configuration": {
69 | "title": "LiveScript",
70 | "properties": {
71 | "livescript.executionMode": {
72 | "type": "string",
73 | "default": "block",
74 | "enum": ["expression", "block"],
75 | "enumDescriptions": [
76 | "Execute individual expressions",
77 | "Execute blocks of adjacent expressions"
78 | ],
79 | "description": "Controls whether to execute individual expressions or blocks of adjacent expressions"
80 | }
81 | }
82 | }
83 | },
84 | "scripts": {},
85 | "devDependencies": {
86 | "@types/vscode": "^1.73.0"
87 | }
88 | }
--------------------------------------------------------------------------------