├── .python-version ├── test ├── single_form_file.clj ├── simple_ns.clj ├── reader_conditionals.cljc ├── error_syntax.clj ├── error_undefined.cljc ├── multiple_nses.clj ├── stacktraces.cljc ├── complex_ns.clj └── forms.clj ├── .gitignore ├── .github └── FUNDING.yml ├── README.md ├── script ├── nrepl.py ├── repl.py └── prepl.py ├── LICENSE ├── Default.sublime-commands ├── Default.sublime-keymap ├── src ├── middleware.clj └── bencode.py └── package.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.8 -------------------------------------------------------------------------------- /test/single_form_file.clj: -------------------------------------------------------------------------------- 1 | (+ 1 2) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | .cpcache -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tonsky] 2 | patreon: tonsky -------------------------------------------------------------------------------- /test/simple_ns.clj: -------------------------------------------------------------------------------- 1 | (ns abc) 2 | 3 | *ns* 4 | 5 | (defn f []) -------------------------------------------------------------------------------- /test/reader_conditionals.cljc: -------------------------------------------------------------------------------- 1 | 2 | #?(:clj :clj 3 | :cljs :cljs) 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repo was merged into [Sublime Clojure](https://github.com/tonsky/sublime-clojure) 2 | -------------------------------------------------------------------------------- /test/error_syntax.clj: -------------------------------------------------------------------------------- 1 | (defn f []) 2 | 3 | (defn g [] 4 | (let [x 1 5 | y 2]])) 6 | 7 | (defn h []) -------------------------------------------------------------------------------- /test/error_undefined.cljc: -------------------------------------------------------------------------------- 1 | (defn f []) 2 | 3 | (defn g [] 4 | (let [x undefined-1 5 | y undefined-2])) 6 | 7 | (defn h []) -------------------------------------------------------------------------------- /test/multiple_nses.clj: -------------------------------------------------------------------------------- 1 | (defn fun [] 2 | *ns*) 3 | 4 | *ns* 5 | 6 | (ns first) 7 | 8 | *ns* 9 | 10 | (defn fun [] 11 | *ns*) 12 | 13 | *ns* 14 | 15 | (ns second) 16 | 17 | *ns* 18 | 19 | (defn fun [] 20 | *ns*) 21 | 22 | *ns* -------------------------------------------------------------------------------- /test/stacktraces.cljc: -------------------------------------------------------------------------------- 1 | (ns stacktraces) 2 | 3 | (defn f [] 4 | (throw (ex-info "Error" {:data :data}))) 5 | 6 | (defn g [] 7 | (f)) 8 | 9 | (defn h [] 10 | (g)) 11 | 12 | (meta #'h) 13 | 14 | (h) 15 | 16 | (defn -main [& args] 17 | (h)) 18 | -------------------------------------------------------------------------------- /script/nrepl.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import os, subprocess 3 | 4 | if __name__ == '__main__': 5 | os.chdir(os.path.dirname(__file__)) 6 | subprocess.check_call(['clojure', 7 | '-Sdeps', '{:deps {nrepl/nrepl {:mvn/version "0.8.3"}}}', 8 | '-M', '-m', 'nrepl.cmdline', 9 | '--port', '5555' 10 | ]) 11 | -------------------------------------------------------------------------------- /script/repl.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import os, subprocess 3 | 4 | if __name__ == '__main__': 5 | os.chdir(os.path.dirname(__file__)) 6 | subprocess.check_call(['clojure', 7 | '-X', 'clojure.core.server/start-server', 8 | ':name', 'prerpl', 9 | ':port', '5555', 10 | ':accept', 'clojure.core.server/repl', 11 | ':server-daemon', 'false' 12 | ]) 13 | -------------------------------------------------------------------------------- /script/prepl.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import os, subprocess 3 | 4 | if __name__ == '__main__': 5 | os.chdir(os.path.dirname(__file__)) 6 | subprocess.check_call(['clojure', 7 | '-X', 'clojure.core.server/start-server', 8 | ':name', 'prerpl', 9 | ':port', '5555', 10 | ':accept', 'clojure.core.server/io-prepl', 11 | ':server-daemon', 'false' 12 | ]) 13 | -------------------------------------------------------------------------------- /test/complex_ns.clj: -------------------------------------------------------------------------------- 1 | ;;; test.clj: a test file to check eval plugin 2 | 3 | ;; by Nikita Prokopov 4 | ;; October 2021 5 | 6 | (ns ^{:doc "Hey! 7 | Nice namespace" 8 | :added ['asdas #"regexp"] 9 | :author "Niki Tonsky"} 10 | sublime-clojure-repl.test 11 | (:require 12 | [clojure.string :as str])) 13 | 14 | *ns* 15 | 16 | (find-ns 'sublime-clojure-repl.test) 17 | 18 | (str/join ", " (range 10)) 19 | 20 | (defn fun [] 21 | *ns*) 22 | 23 | (defn f2 24 | "Test function" 25 | ([] (f2 1)) 26 | ([a] (f2 1 a)) 27 | ([a & rest] (println a rest))) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nikita Prokopov 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 | -------------------------------------------------------------------------------- /Default.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Clojure REPL: Connect", 4 | "command": "connect" 5 | }, 6 | { 7 | "caption": "Clojure REPL: Disconnect", 8 | "command": "disconnect" 9 | }, 10 | { 11 | "caption": "Clojure REPL: Reconnect", 12 | "command": "reconnect" 13 | }, 14 | { 15 | "caption": "Clojure REPL: Eval Selection", 16 | "command": "eval_selection" 17 | }, 18 | { 19 | "caption": "Clojure REPL: Eval Topmost Form", 20 | "command": "eval_topmost_form" 21 | }, 22 | { 23 | "caption": "Clojure REPL: Eval Buffer", 24 | "command": "eval_buffer" 25 | }, 26 | { 27 | "caption": "Clojure REPL: Clear evaluation results", 28 | "command": "clear_evals" 29 | }, 30 | { 31 | "caption": "Clojure REPL: Interrupt current evaluation", 32 | "command": "interrupt_eval" 33 | }, 34 | { 35 | "caption": "Clojure REPL: Lookup Symbol", 36 | "command": "lookup_symbol" 37 | }, 38 | { 39 | "caption": "Clojure REPL: Toggle Stacktrace", 40 | "command": "toggle_trace" 41 | }, 42 | ] -------------------------------------------------------------------------------- /Default.sublime-keymap: -------------------------------------------------------------------------------- 1 | [{"keys": ["super+enter"], 2 | "command": "eval_selection", 3 | "context": [{"key": "selection_empty", "operator": "equal", "operand": false}, 4 | {"key": "selector", "operator": "equal", "operand": "source.clojurec", "match_all": true}]}, 5 | 6 | {"keys": ["super+enter"], 7 | "command": "eval_topmost_form", 8 | "context": [{"key": "selection_empty", "operator": "equal", "operand": true}, 9 | {"key": "selector", "operator": "equal", "operand": "source.clojurec", "match_all": true}]}, 10 | 11 | {"keys": ["super+shift+enter"], 12 | "command": "eval_buffer", 13 | "context": [{"key": "selector", "operator": "equal", "operand": "source.clojurec"}]}, 14 | 15 | {"keys": ["ctrl+l"], 16 | "command": "clear_evals", 17 | "context": [{"key": "selector", "operator": "equal", "operand": "source.clojurec"}]}, 18 | 19 | {"keys": ["ctrl+c"], 20 | "command": "interrupt_eval", 21 | "context": [{"key": "selector", "operator": "equal", "operand": "source.clojurec"}]}, 22 | 23 | {"keys": ["ctrl+d"], 24 | "command": "lookup_symbol", 25 | "context": [{"key": "selector", "operator": "equal", "operand": "source.clojurec"}]}, 26 | 27 | {"keys": ["ctrl+e"], 28 | "command": "toggle_trace", 29 | "context": [{"key": "selector", "operator": "equal", "operand": "source.clojurec"}]}, 30 | ] -------------------------------------------------------------------------------- /test/forms.clj: -------------------------------------------------------------------------------- 1 | ; simple expr 2 | (+ 1 2) 3 | (+ 1 2) 4 | 5 | ; long value 6 | (range 300) 7 | 8 | ; delayed eval 9 | (do (Thread/sleep 1000) :done) 10 | 11 | ; infinite sequence 12 | (range) 13 | 14 | ; print 15 | (println "Hello, Sublime!") 16 | (.println System/out "System.out.println") 17 | 18 | ; print to stderr 19 | (binding [*out* *err*] (println "abc")) 20 | (.println System/err "System.err.println") 21 | 22 | ; print in background 23 | (doseq [i (range 0 10)] (Thread/sleep 1000) (println i)) 24 | 25 | ; throw exception 26 | (throw (ex-info 27 | "abc" {:a 1})) 28 | (throw (Exception. "ex with msg")) 29 | *e 30 | 31 | ; truncating 32 | (range) 33 | (throw (ex-info "abc" {:a (range 300)})) 34 | 35 | ; lookups 36 | var 37 | if 38 | cond 39 | *out* 40 | *warn-on-reflection* 41 | set! 42 | clojure.set/join 43 | find-keyword 44 | (def ^{:doc "Doc"} const 0) 45 | (defn f2 46 | "Test function" 47 | ([] (f2 1)) 48 | ([a] (f2 1 a)) 49 | ([a & rest] (println a rest))) 50 | 51 | ; wrapped exception (RuntimeException inside CompilerException) 52 | unresolved-symbol 53 | 54 | (defn f []) 55 | 56 | (defn g [] 57 | (let [x unresolved-symbol] 58 | )) 59 | 60 | ; top-level forms 61 | true 62 | false 63 | symbol 64 | :keyword 65 | :namespaced/keyword 66 | ::keyword 67 | ::namespaced/keyword 68 | \n 69 | \newline 70 | \o377 71 | 100 72 | 100N 73 | 100.0 74 | 1/2 75 | "string" 76 | #"regex" 77 | @deref 78 | #'var 79 | #_"hello" 80 | #inst "2021-10-20" 81 | #uuid "d3e13f30-85b1-4334-9b67-5e6d580e266c" 82 | #p "abc" 83 | ^:false sym 84 | ^{:meta [true false]} sym 85 | [1 2 3] 86 | '(1 2 3) 87 | {:a 1 :b 2 :c 3} 88 | '({[] #{}}) 89 | [{() #{}}] 90 | {[#{()}] '(((())))} 91 | #{[()]} 92 | 93 | ; comment forms 94 | (comment 95 | "hello" 96 | (println "hello")) 97 | (comment "hello") 98 | (comment "hello" ) 99 | 100 | ; column reports for Unicode 101 | #"alkjdljl👨🏿kjlkj👨🏻‍🤝‍👨🏼ljasljlkjasjasljas\u" 102 | 103 | ; two forms 104 | (+ 1 2)(+ 3 4) 105 | :first(Thread/sleep 1000):second 106 | 107 | ; malformed expr 108 | (+ 1 109 | (+ 1 2)) 110 | -------------------------------------------------------------------------------- /src/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns sublime-clojure-repl.middleware 2 | (:require 3 | [clojure.main :as main] 4 | [clojure.stacktrace :as stacktrace] 5 | [clojure.string :as str] 6 | [nrepl.middleware :as middleware] 7 | [nrepl.middleware.print :as print] 8 | [nrepl.middleware.caught :as caught] 9 | [nrepl.transport :as transport]) 10 | (:import 11 | [nrepl.transport Transport])) 12 | 13 | (defn- root-cause [^Throwable t] 14 | (when t 15 | (loop [t t] 16 | (if-some [cause (.getCause t)] 17 | (recur cause) 18 | t)))) 19 | 20 | (defn print-root-trace [^Throwable t] 21 | (stacktrace/print-stack-trace (root-cause t))) 22 | 23 | (defn trace [^Throwable t] 24 | (let [trace (with-out-str 25 | (.printStackTrace t (java.io.PrintWriter. *out*)))] 26 | (if-some [idx (str/index-of trace "\n\tat clojure.lang.Compiler.eval(Compiler.java:")] 27 | (subs trace 0 idx) 28 | trace))) 29 | 30 | (defn- caught-transport [{:keys [transport] :as msg}] 31 | (reify Transport 32 | (recv [this] 33 | (transport/recv transport)) 34 | (recv [this timeout] 35 | (transport/recv transport timeout)) 36 | (send [this {throwable ::caught/throwable :as resp}] 37 | (let [root (root-cause throwable) 38 | data (ex-data root) 39 | loc (when (instance? clojure.lang.Compiler$CompilerException throwable) 40 | {::line (or (.-line throwable) (:clojure.error/line (ex-data throwable))) 41 | ::column (:clojure.error/column (ex-data throwable)) 42 | ::source (or (.-source throwable) (:clojure.error/source (ex-data throwable)))}) 43 | resp' (cond-> resp 44 | root (assoc 45 | ::root-ex-msg (.getMessage root) 46 | ::root-ex-class (.getSimpleName (class root)) 47 | ::trace (trace root)) 48 | loc (merge loc) 49 | data (update ::print/keys (fnil conj []) ::root-ex-data) 50 | data (assoc ::root-ex-data data))] 51 | (transport/send transport resp')) 52 | this))) 53 | 54 | (defn wrap-errors [handler] 55 | (fn [msg] 56 | (handler (assoc msg :transport (caught-transport msg))))) 57 | 58 | (middleware/set-descriptor! 59 | #'wrap-errors 60 | {:requires #{#'caught/wrap-caught} ;; run inside wrap-caught 61 | :expects #{"eval"} ;; but outside of "eval" 62 | :handles {}}) 63 | 64 | (defn- output-transport [{:keys [transport] :as msg}] 65 | (reify Transport 66 | (recv [this] 67 | (transport/recv transport)) 68 | (recv [this timeout] 69 | (transport/recv transport timeout)) 70 | (send [this resp] 71 | (when-some [out (:out resp)] 72 | (.print System/out out) 73 | (.flush System/out)) 74 | (when-some [err (:err resp)] 75 | (.print System/err err) 76 | (.flush System/err)) 77 | (transport/send transport resp) 78 | this))) 79 | 80 | (defn wrap-output [handler] 81 | (fn [msg] 82 | (handler (assoc msg :transport (output-transport msg))))) 83 | 84 | (middleware/set-descriptor! 85 | #'wrap-output 86 | {:requires #{} 87 | :expects #{"eval"} ;; run outside of "eval" 88 | :handles {}}) 89 | -------------------------------------------------------------------------------- /src/bencode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ''' 4 | nrepl.bencode 5 | ------------- 6 | This module provides BEncode-protocol support. 7 | :copyright: (c) 2013 by Chas Emerick. 8 | :license: MIT, see LICENSE for more details. 9 | ''' 10 | 11 | 12 | from io import StringIO, BytesIO 13 | import array, numbers, sys 14 | from io import BytesIO 15 | 16 | def _read_byte(s): 17 | return s.read(1) 18 | 19 | def _read_int(s, terminator=None, init_data=None): 20 | int_chrs = init_data or [] 21 | while True: 22 | c = _read_byte(s) 23 | if not (c.isdigit() or c == b'-') or c == terminator or not c: 24 | break 25 | else: 26 | int_chrs.append(c) 27 | return int(b''.join(int_chrs)) 28 | 29 | 30 | def _read_bytes(s, n): 31 | data = BytesIO() 32 | cnt = 0 33 | while cnt < n: 34 | m = s.read(n - cnt) 35 | if not m: 36 | raise Exception("Invalid bytestring, unexpected end of input.") 37 | data.write(m) 38 | cnt += len(m) 39 | data.flush() 40 | # Taking into account that Python3 can't decode strings 41 | try: 42 | ret = data.getvalue().decode("UTF-8") 43 | except AttributeError: 44 | ret = data.getvalue() 45 | return ret 46 | 47 | 48 | def _read_delimiter(s): 49 | d = _read_byte(s) 50 | if d.isdigit(): 51 | d = _read_int(s, ":", [d]) 52 | return d 53 | 54 | 55 | def _read_list(s): 56 | data = [] 57 | while True: 58 | datum = _read_datum(s) 59 | if datum is None: 60 | break 61 | data.append(datum) 62 | return data 63 | 64 | 65 | def _read_map(s): 66 | i = iter(_read_list(s)) 67 | return dict(zip(i, i)) 68 | 69 | 70 | _read_fns = {b"i": _read_int, 71 | b"l": _read_list, 72 | b"d": _read_map, 73 | b"e": lambda _: None, 74 | # EOF 75 | None: lambda _: None} 76 | 77 | 78 | def _read_datum(s): 79 | delim = _read_delimiter(s) 80 | if delim != b'': 81 | return _read_fns.get(delim, lambda s: _read_bytes(s, delim))(s) 82 | 83 | 84 | def _write_datum(x, out): 85 | if isinstance(x, (str, bytes)): 86 | # x = x.encode("UTF-8") 87 | # TODO revisit encodings, this is surely not right. Python 88 | # (2.x, anyway) conflates bytes and strings, but 3.x does not... 89 | out.write(str(len(x.encode('utf-8'))).encode('utf-8')) 90 | out.write(b":") 91 | out.write(x.encode('utf-8')) 92 | elif isinstance(x, numbers.Integral): 93 | out.write(b"i") 94 | out.write(str(x).encode('utf-8')) 95 | out.write(b"e") 96 | elif isinstance(x, (list, tuple)): 97 | out.write(b"l") 98 | for v in x: 99 | _write_datum(v, out) 100 | out.write(b"e") 101 | elif isinstance(x, dict): 102 | out.write(b"d") 103 | for k, v in x.items(): 104 | _write_datum(k, out) 105 | _write_datum(v, out) 106 | out.write(b"e") 107 | out.flush() 108 | 109 | 110 | def encode(v): 111 | "bencodes the given value, may be a string, integer, list, or dict." 112 | s = BytesIO() 113 | _write_datum(v, s) 114 | return s.getvalue().decode('utf-8') 115 | 116 | 117 | def decode_file(file): 118 | while True: 119 | x = _read_datum(file) 120 | if x is None: 121 | break 122 | yield x 123 | 124 | 125 | def decode(string): 126 | "Generator that yields decoded values from the input string." 127 | return decode_file(BytesIO(string.encode('utf-8'))) 128 | 129 | 130 | class BencodeIO(object): 131 | def __init__(self, file, on_close=None): 132 | self._file = file 133 | self._on_close = on_close 134 | 135 | def read(self): 136 | return _read_datum(self._file) 137 | 138 | def __iter__(self): 139 | return self 140 | 141 | def next(self): 142 | v = self.read() 143 | if not v: 144 | raise StopIteration 145 | return v 146 | 147 | def __next__(self): 148 | # In Python3, __next__ it is an own special class. 149 | v = self.read() 150 | if not v: 151 | raise StopIteration 152 | return v 153 | 154 | def write(self, v): 155 | return _write_datum(v, self._file) 156 | 157 | def flush(self): 158 | if self._file.flush: 159 | self._file.flush() 160 | 161 | def close(self): 162 | # Run the on_close handler if one exists, which can do something 163 | # useful like cleanly close a socket. (Note that .close() on a 164 | # socket.makefile('rw') does some kind of unclean close.) 165 | if self._on_close is not None: 166 | self._on_close() 167 | else: 168 | self._file.close() 169 | 170 | if __name__ == '__main__': 171 | import json, socket, threading 172 | conn = socket.create_connection(("localhost", 5555)) 173 | 174 | def read_loop(conn): 175 | while msg := conn.recv(4096): 176 | # print("RCV RAW", msg.decode()) 177 | payload = msg.decode() 178 | parsed = next(decode(payload)) 179 | print("RCV", json.dumps(parsed)) 180 | threading.Thread(daemon=True, target=read_loop, args=(conn,)).start() 181 | 182 | for line in sys.stdin: 183 | try: 184 | parsed = json.loads(line) 185 | encoded = encode(parsed) 186 | # print("SND RAW", encoded.encode()) 187 | conn.sendall(encoded.encode()) 188 | except json.JSONDecodeError: 189 | print("Not a valid JSON") 190 | -------------------------------------------------------------------------------- /package.py: -------------------------------------------------------------------------------- 1 | import html, json, os, re, socket, sublime, sublime_plugin, threading 2 | from collections import defaultdict 3 | from .src import bencode 4 | from typing import Any, Dict 5 | 6 | ns = 'sublime-clojure-repl' 7 | 8 | class Eval: 9 | # class 10 | next_id: int = 10 11 | 12 | # instance 13 | id: int 14 | view: sublime.View 15 | status: str # "clone" | "eval" | "interrupt" | "success" | "exception" 16 | code: str 17 | session: str 18 | msg: Dict[str, Any] 19 | trace: str 20 | trace_key: int 21 | 22 | def __init__(self, view, region, status, value): 23 | self.id = Eval.next_id 24 | self.view = view 25 | self.status = status 26 | self.code = view.substr(region) 27 | self.session = None 28 | self.msg = None 29 | self.trace = None 30 | self.trace_key = None 31 | 32 | Eval.next_id += 1 33 | scope, color = self.scope_color() 34 | view.add_regions(self.value_key(), [region], scope, '', sublime.DRAW_NO_FILL, [value], color) 35 | 36 | def value_key(self): 37 | return f"{ns}.eval-{self.id}" 38 | 39 | def scope_color(self): 40 | if "success" == self.status: 41 | return ("region.greenish", '#7CCE9B') 42 | elif "exception" == self.status: 43 | return ("region.redish", '#DD1730') 44 | else: 45 | return ("region.bluish", '#7C9BCE') 46 | 47 | def region(self): 48 | regions = self.view.get_regions(self.value_key()) 49 | if regions and len(regions) >= 1: 50 | return regions[0] 51 | 52 | def update(self, status, value, region = None): 53 | self.status = status 54 | region = region or self.region() 55 | if region: 56 | scope, color = self.scope_color() 57 | self.view.add_regions(self.value_key(), [region], scope, '', sublime.DRAW_NO_FILL, [value], color) 58 | 59 | def toggle_trace(self): 60 | if self.trace: 61 | if self.trace_key: 62 | self.view.erase_phantom_by_id(self.trace_key) 63 | self.trace_key = None 64 | else: 65 | settings = self.view.settings() 66 | top = settings.get('line_padding_top', 0) 67 | bottom = settings.get('line_padding_bottom', 0) 68 | body = f"""""" 72 | for line in html.escape(self.trace).split("\n"): 73 | body += "

" + line.replace("\t", "  ") + "

" 74 | region = self.region() 75 | if region: 76 | point = self.view.line(region.end()).begin() 77 | self.trace_key = self.view.add_phantom(self.value_key(), sublime.Region(point, point), body, sublime.LAYOUT_BLOCK) 78 | 79 | def erase(self): 80 | self.view.erase_regions(self.value_key()) 81 | if self.trace_key: 82 | self.view.erase_phantom_by_id(self.trace_key) 83 | 84 | class Connection: 85 | def __init__(self): 86 | self.host = 'localhost' 87 | self.port = 5555 88 | self.evals: dict[int, Eval] = {} 89 | self.reset() 90 | 91 | def set_status(self, status): 92 | self.status = status 93 | self.refresh_status() 94 | 95 | def refresh_status(self): 96 | if sublime.active_window(): 97 | view = sublime.active_window().active_view() 98 | if view: 99 | view.set_status(ns, self.status) 100 | 101 | def send(self, msg): 102 | print(">>>", msg) 103 | self.socket.sendall(bencode.encode(msg).encode()) 104 | 105 | def reset(self): 106 | self.socket = None 107 | self.reader = None 108 | self.session = None 109 | self.set_status('🌑 Offline') 110 | for id, eval in self.evals.items(): 111 | eval.erase() 112 | self.evals.clear() 113 | 114 | def add_eval(self, eval): 115 | self.evals[eval.id] = eval 116 | 117 | def erase_eval(self, eval): 118 | eval.erase() 119 | del self.evals[eval.id] 120 | 121 | def erase_evals(self, predicate, view = None): 122 | for id, eval in list(self.evals.items()): 123 | if (view == None or view == eval.view) and predicate(eval): 124 | self.erase_eval(eval) 125 | 126 | def disconnect(self): 127 | if self.socket: 128 | self.socket.close() 129 | self.reset() 130 | 131 | conn = Connection() 132 | 133 | def handle_new_session(msg): 134 | if "new-session" in msg and "id" in msg and msg["id"] in conn.evals: 135 | eval = conn.evals[msg["id"]] 136 | eval.session = msg["new-session"] 137 | eval.msg["session"] = msg["new-session"] 138 | conn.send(eval.msg) 139 | eval.update("eval", "Evaluating...") 140 | return True 141 | 142 | def handle_value(msg): 143 | if "value" in msg and "id" in msg and msg["id"] in conn.evals: 144 | eval = conn.evals[msg["id"]] 145 | eval.update("success", msg.get("value")) 146 | return True 147 | 148 | def handle_exception(msg): 149 | if "id" in msg and msg["id"] in conn.evals: 150 | eval = conn.evals[msg["id"]] 151 | get = lambda key: msg.get(ns + ".middleware/" + key) 152 | if get("root-ex-class") and get("root-ex-msg"): 153 | text = get("root-ex-class") + ": " + get("root-ex-msg") 154 | region = None 155 | if get("root-ex-data"): 156 | text += " " + get("root-ex-data") 157 | if get("line") and get("column"): 158 | line = get("line") 159 | column = get("column") 160 | point = eval.view.text_point_utf16(line - 1, column - 1, clamp_column = True) 161 | region = sublime.Region(point, eval.view.line(point).end()) 162 | eval.trace = get("trace") 163 | eval.update("exception", text, region) 164 | return True 165 | elif "root-ex" in msg: 166 | eval.update("exception", msg["root-ex"]) 167 | return True 168 | elif "ex" in msg: 169 | eval.update("exception", msg["ex"]) 170 | return True 171 | elif "status" in msg and "namespace-not-found" in msg["status"]: 172 | eval.update("exception", f'Namespace not found: {msg["ns"]}') 173 | 174 | def namespace(view, point): 175 | ns = None 176 | for region in view.find_by_selector("entity.name"): 177 | if region.end() <= point: 178 | begin = region.begin() 179 | while begin > 0 and view.match_selector(begin - 1, 'meta.parens'): 180 | begin -= 1 181 | if re.match(r"\([\s,]*ns[\s,]", view.substr(sublime.Region(begin, region.begin()))): 182 | ns = view.substr(region) 183 | else: 184 | break 185 | return ns 186 | 187 | def eval_msg(view, region, msg): 188 | extended_region = view.line(region) 189 | conn.erase_evals(lambda eval: eval.region() and eval.region().intersects(extended_region), view) 190 | eval = Eval(view, region, "clone", "Cloning...") 191 | eval.msg = {k: v for k, v in msg.items() if v} 192 | eval.msg["id"] = eval.id 193 | eval.msg["nrepl.middleware.caught/caught"] = "sublime-clojure-repl.middleware/print-root-trace" 194 | eval.msg["nrepl.middleware.print/quota"] = 300 195 | conn.add_eval(eval) 196 | conn.send({"op": "clone", "session": conn.session, "id": eval.id}) 197 | 198 | def eval(view, region): 199 | (line, column) = view.rowcol_utf16(region.begin()) 200 | msg = {"op": "eval", 201 | "code": view.substr(region), 202 | "ns": namespace(view, region.begin()) or 'user', 203 | "line": line, 204 | "column": column, 205 | "file": view.file_name()} 206 | eval_msg(view, region, msg) 207 | 208 | def expand_until(view, point, scopes): 209 | if view.scope_name(point) in scopes and point > 0: 210 | point = point - 1 211 | if view.scope_name(point) not in scopes: 212 | begin = point 213 | while begin > 0 and view.scope_name(begin - 1) not in scopes: 214 | begin -= 1 215 | end = point 216 | while end < view.size() and view.scope_name(end) not in scopes: 217 | end += 1 218 | return sublime.Region(begin, end) 219 | 220 | def topmost_form(view, point): 221 | region = expand_until(view, point, {'source.clojurec '}) 222 | if region \ 223 | and view.substr(region).startswith("(comment") \ 224 | and point >= region.begin() + len("(comment ") \ 225 | and point < region.end(): 226 | return expand_until(view, point, {'source.clojurec meta.parens.clojure ', 227 | 'source.clojurec meta.parens.clojure punctuation.section.parens.end.clojure '}) 228 | return region 229 | 230 | class EvalTopmostFormCommand(sublime_plugin.TextCommand): 231 | def run(self, edit): 232 | point = self.view.sel()[0].begin() 233 | region = topmost_form(self.view, point) 234 | if region: 235 | eval(self.view, region) 236 | 237 | def is_enabled(self): 238 | return conn.socket != None \ 239 | and conn.session != None \ 240 | and len(self.view.sel()) == 1 241 | 242 | class EvalSelectionCommand(sublime_plugin.TextCommand): 243 | def run(self, edit): 244 | region = self.view.sel()[0] 245 | eval(self.view, region) 246 | 247 | def is_enabled(self): 248 | return conn.socket != None \ 249 | and conn.session != None \ 250 | and len(self.view.sel()) == 1 \ 251 | and not self.view.sel()[0].empty() 252 | 253 | class EvalBufferCommand(sublime_plugin.TextCommand): 254 | def run(self, edit): 255 | view = self.view 256 | region = sublime.Region(0, view.size()) 257 | file_name = view.file_name() 258 | msg = {"op": "load-file", 259 | "file": view.substr(region), 260 | "file-path": file_name, 261 | "file-name": os.path.basename(file_name) if file_name else "NO_SOURCE_FILE.cljc"} 262 | eval_msg(view, region, msg) 263 | 264 | def is_enabled(self): 265 | return conn.socket != None \ 266 | and conn.session != None 267 | 268 | class ClearEvalsCommand(sublime_plugin.TextCommand): 269 | def run(self, edit): 270 | conn.erase_evals(lambda eval: eval.status in {"success", "exception"}, self.view) 271 | 272 | class InterruptEvalCommand(sublime_plugin.TextCommand): 273 | def run(self, edit): 274 | for eval in conn.evals.values(): 275 | if eval.status == "eval": 276 | conn.send({"op": "interrupt", 277 | "session": eval.session, 278 | "interrupt-id": eval.id}) 279 | eval.update("interrupt", "Interrupting...") 280 | 281 | def is_enabled(self): 282 | return conn.socket != None \ 283 | and conn.session != None 284 | 285 | class ToggleTraceCommand(sublime_plugin.TextCommand): 286 | def run(self, edit): 287 | view = self.view 288 | point = view.sel()[0].begin() 289 | for eval in conn.evals.values(): 290 | if eval.view == view: 291 | region = eval.region() 292 | if region and region.contains(point): 293 | eval.toggle_trace() 294 | break 295 | 296 | def is_enabled(self): 297 | return conn.socket != None \ 298 | and conn.session != None \ 299 | and len(self.view.sel()) == 1 300 | 301 | def format_lookup(info): 302 | ns = info.get('ns') 303 | name = info['name'] 304 | file = info.get('file') 305 | arglists = info.get('arglists') 306 | forms = info.get('forms') 307 | doc = info.get('doc') 308 | 309 | body = """ 310 | """ 316 | 317 | body += "

" 318 | if file: 319 | body += f"" 320 | if ns: 321 | body += html.escape(ns) + "/" 322 | body += html.escape(name) 323 | if file: 324 | body += f"" 325 | body += "

" 326 | 327 | if arglists: 328 | body += f'

{html.escape(arglists.strip("()"))}

' 329 | 330 | if forms: 331 | def format_form(form): 332 | if isinstance(form, str): 333 | return form 334 | else: 335 | return "(" + " ".join([format_form(x) for x in form]) + ")" 336 | body += '

' 337 | body += html.escape(" ".join([format_form(form) for form in forms])) 338 | body += "

" 339 | 340 | if doc: 341 | body += "

" + html.escape(doc).replace("\n", "
") + "

" 342 | 343 | body += "" 344 | return body 345 | 346 | def handle_lookup(msg): 347 | if "info" in msg: 348 | view = sublime.active_window().active_view() 349 | if msg["info"]: 350 | view.show_popup(format_lookup(msg["info"]), max_width=1024) 351 | else: 352 | view.show_popup("Not found") 353 | 354 | class LookupSymbolCommand(sublime_plugin.TextCommand): 355 | def run(self, edit): 356 | view = self.view 357 | region = self.view.sel()[0] 358 | if region.empty(): 359 | point = region.begin() 360 | if view.match_selector(point, 'source.symbol.clojure'): 361 | region = self.view.extract_scope(point) 362 | elif point > 0 and view.match_selector(point - 1, 'source.symbol.clojure'): 363 | region = self.view.extract_scope(point - 1) 364 | if not region.empty(): 365 | conn.send({"op": "lookup", 366 | "sym": view.substr(region), 367 | "session": conn.session, 368 | "id": Eval.next_id, 369 | "ns": namespace(view, region.begin()) or 'user'}) 370 | Eval.next_id += 1 371 | 372 | def is_enabled(self): 373 | if conn.socket == None or conn.session == None: 374 | return False 375 | view = self.view 376 | if len(view.sel()) > 1: 377 | return False 378 | region = view.sel()[0] 379 | if not region.empty(): 380 | return True 381 | point = region.begin() 382 | if view.match_selector(point, 'source.symbol.clojure'): 383 | return True 384 | if point > 0 and view.match_selector(point - 1, 'source.symbol.clojure'): 385 | return True 386 | return False 387 | 388 | class EventListener(sublime_plugin.EventListener): 389 | def on_activated(self, view): 390 | conn.refresh_status() 391 | 392 | def on_modified_async(self, view): 393 | conn.erase_evals(lambda eval: eval.region() and view.substr(eval.region()) != eval.code, view) 394 | 395 | def on_close(self, view): 396 | conn.erase_evals(lambda eval: True, view) 397 | 398 | class SocketIO: 399 | def __init__(self, socket): 400 | self.socket = socket 401 | self.buffer = None 402 | self.pos = -1 403 | 404 | def read(self, n): 405 | if not self.buffer or self.pos >= len(self.buffer): 406 | self.buffer = self.socket.recv(4096) 407 | # print("<<<", self.buffer.decode()) 408 | self.pos = 0 409 | begin = self.pos 410 | end = min(begin + n, len(self.buffer)) 411 | self.pos = end 412 | return self.buffer[begin:end] 413 | 414 | def handle_connect(msg): 415 | if 1 == msg.get("id") and "new-session" in msg: 416 | conn.session = msg["new-session"] 417 | with open(os.path.join(sublime.packages_path(), "sublime-clojure-repl", "src", "middleware.clj"), "r") as file: 418 | conn.send({"op": "load-file", 419 | "session": conn.session, 420 | "file": file.read(), 421 | "id": 2}) 422 | conn.set_status("🌓 Uploading middlewares") 423 | return True 424 | 425 | elif 2 == msg.get("id") and msg.get("status") == ["done"]: 426 | conn.send({"op": "add-middleware", 427 | "middleware": ["sublime-clojure-repl.middleware/wrap-errors", 428 | "sublime-clojure-repl.middleware/wrap-output"], 429 | "extra-namespaces": ["sublime-clojure-repl.middleware"], 430 | "session": conn.session, 431 | "id": 3}) 432 | conn.set_status("🌔 Adding middlewares") 433 | return True 434 | 435 | elif 3 == msg.get("id") and msg.get("status") == ["done"]: 436 | conn.set_status(f"🌕 {conn.host}:{conn.port}") 437 | return True 438 | 439 | def handle_done(msg): 440 | if "id" in msg and msg["id"] in conn.evals and "status" in msg and "done" in msg["status"]: 441 | eval = conn.evals[msg["id"]] 442 | if eval.status not in {"success", "exception"}: 443 | conn.erase_eval(eval) 444 | 445 | def handle_msg(msg): 446 | print("<<<", msg) 447 | 448 | for key in msg.get('nrepl.middleware.print/truncated-keys', []): 449 | msg[key] += '...' 450 | 451 | handle_connect(msg) \ 452 | or handle_new_session(msg) \ 453 | or handle_value(msg) \ 454 | or handle_exception(msg) \ 455 | or handle_lookup(msg) \ 456 | or handle_done(msg) 457 | 458 | def read_loop(): 459 | try: 460 | conn.pending_id = 1 461 | conn.send({"op": "clone", "id": conn.pending_id}) 462 | conn.set_status(f"🌒 Cloning session") 463 | for msg in bencode.decode_file(SocketIO(conn.socket)): 464 | handle_msg(msg) 465 | except OSError: 466 | pass 467 | conn.disconnect() 468 | 469 | def connect(host, port): 470 | conn.host = host 471 | conn.port = port 472 | try: 473 | conn.socket = socket.create_connection((host, port)) 474 | conn.reader = threading.Thread(daemon=True, target=read_loop) 475 | conn.reader.start() 476 | except Exception as e: 477 | print(e) 478 | conn.socket = None 479 | conn.set_status(f"🌑 {host}:{port}") 480 | 481 | class HostPortInputHandler(sublime_plugin.TextInputHandler): 482 | def placeholder(self): 483 | return "host:port" 484 | 485 | def initial_text(self): 486 | if conn.host and conn.port: 487 | return f'{conn.host}:{conn.port}' 488 | 489 | def preview(self, text): 490 | if not self.validate(text): 491 | return "Invalid, expected :" 492 | 493 | def validate(self, text): 494 | text = text.strip() 495 | if not re.fullmatch(r'[a-zA-Z0-9\.]+:\d{1,5}', text): 496 | return False 497 | host, port = text.split(':') 498 | port = int(port) 499 | return 0 <= port and port <= 65536 500 | 501 | class ConnectCommand(sublime_plugin.ApplicationCommand): 502 | def run(self, host_port): 503 | host, port = host_port.strip().split(':') 504 | port = int(port) 505 | connect(host, port) 506 | 507 | def input(self, args): 508 | return HostPortInputHandler() 509 | 510 | def is_enabled(self): 511 | return conn.socket == None 512 | 513 | class DisconnectCommand(sublime_plugin.ApplicationCommand): 514 | def run(self): 515 | conn.disconnect() 516 | 517 | def is_enabled(self): 518 | return conn.socket != None 519 | 520 | class ReconnectCommand(sublime_plugin.ApplicationCommand): 521 | def run(self): 522 | conn.disconnect() 523 | connect(conn.host, conn.port) 524 | 525 | def is_enabled(self): 526 | return conn.socket != None 527 | 528 | def plugin_loaded(): 529 | connect('localhost', 5555) # FIXME 530 | 531 | def plugin_unloaded(): 532 | conn.disconnect() 533 | 534 | 535 | --------------------------------------------------------------------------------