├── .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 |
10 | 11 | Livescript 12 | 13 |
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 | ![LiveScript in action](https://github.com/thmsmlr/livescript/raw/main/vscode-extension/assets/livescript-vscode.png) 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 | ![Start Server](https://github.com/thmsmlr/livescript/raw/main/vscode-extension/assets/livescript-vscode-launch.png) 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 | } --------------------------------------------------------------------------------