├── squint.edn ├── demo ├── src │ ├── deps.cljs │ └── nextjournal │ │ └── clojure_mode │ │ ├── demo │ │ ├── sci.cljs │ │ └── livedoc.cljs │ │ └── demo.cljs └── notebooks │ └── livedoc.md ├── .clj-kondo └── config.edn ├── .gitignore ├── squint-demo ├── package.json ├── index.mjs └── index.html ├── vite.config.js ├── CHANGELOG.md ├── src ├── deps.cljs └── nextjournal │ └── clojure_mode │ ├── live_grammar.cljs │ └── clojure.grammar ├── src-shared └── nextjournal │ ├── clojure_mode │ ├── selections.cljs │ ├── chars.cljs │ ├── test_utils.cljc │ ├── extensions │ │ ├── selection_history.cljc │ │ ├── match_brackets.cljc │ │ ├── close_brackets.cljc │ │ ├── eval_region.cljc │ │ └── formatting.cljc │ ├── keymap.cljs │ ├── util.cljc │ ├── commands.cljc │ └── node.cljc │ └── clojure_mode.cljc ├── test └── nextjournal │ ├── scratch.cljs │ └── clojure_mode_tests.cljc ├── .bb └── tasks.bb ├── deps.edn ├── shadow-cljs.edn ├── package.json ├── README.md ├── src-squint └── nextjournal │ └── clojure_mode_tests │ └── macros.cljc ├── bb.edn ├── .github └── workflows │ └── main.yml ├── public ├── livedoc │ └── index.html ├── squint │ └── js │ │ └── demo.mjs └── index.html ├── resources └── stylesheets │ └── viewer.css └── LICENSE /squint.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src-shared" "src-squint" "test" 2 | "node_modules/@squint-cljs/macros/src"] 3 | :output-dir "dist"} 4 | -------------------------------------------------------------------------------- /demo/src/deps.cljs: -------------------------------------------------------------------------------- 1 | {:npm-deps 2 | {"react" "^17.0.2" 3 | "react-dom" "^17.0.2" 4 | "framer-motion" "^6.2.8" 5 | "@codemirror/lang-javascript" "^6.0.0"}} 6 | -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:cljc {:features [:cljs]} 2 | :lint-as {applied-science.js-interop/defn clojure.core/defn 3 | applied-science.js-interop/fn clojure.core/fn 4 | applied-science.js-interop/let clojure.core/let}} 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .shadow-cljs 2 | .idea 3 | .calva 4 | .cache 5 | .cpcache 6 | public/js 7 | public/livedoc/js 8 | public/squint-cdn-demo 9 | squint-demo/node_modules 10 | node_modules 11 | public/test 12 | *.iml 13 | out 14 | dist 15 | **/.DS_Store 16 | .nrepl-port 17 | -------------------------------------------------------------------------------- /squint-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@codemirror/language": "^6.9.2", 4 | "@codemirror/lang-javascript": "^6.0.0", 5 | "@codemirror/state": "^6.3.1", 6 | "@codemirror/view": "^6.21.4", 7 | "@nextjournal/clojure-mode": "0.1.0" 8 | }, 9 | "devDependencies": { 10 | "jspm": "^3.1.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import analyze from "rollup-plugin-analyzer"; 2 | 3 | export default { 4 | base: './', 5 | optimizeDeps: { 6 | // avoids loading deps multiple time 7 | exclude: ['prosemirror-model', 'y-prosemirror', 'y-websocket'], 8 | // root: "demo" 9 | }, 10 | build: { 11 | rollupOptions: { 12 | plugins: [analyze()] 13 | } 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.3.3 4 | 5 | - Fix [#54](https://github.com/nextjournal/clojure-mode/issues/54): support slurping from within string literal 6 | 7 | ## 0.3.2 8 | 9 | - Bump squint to 0.7.105 10 | - Fix [#49](https://github.com/nextjournal/clojure-mode/issues/49): bug with hitting backspace after line comment 11 | 12 | ## 0.3.1 13 | 14 | - Fix [#45](https://github.com/nextjournal/clojure-mode/issues/45): cursor after Discard node should evaluate child 15 | -------------------------------------------------------------------------------- /src/deps.cljs: -------------------------------------------------------------------------------- 1 | {:npm-deps 2 | {"@codemirror/autocomplete" "^6.0.2" 3 | "@codemirror/commands" "^6.0.0" 4 | "@codemirror/lang-markdown" "6.0.0" 5 | "@codemirror/language" "^6.1.0" 6 | "@codemirror/lint" "^6.0.0" 7 | "@codemirror/search" "^6.0.0" 8 | "@codemirror/state" "^6.0.1" 9 | "@codemirror/view" "^6.0.2" 10 | "@lezer/common" "^1.0.0" 11 | "@lezer/generator" "^1.0.0" 12 | "@lezer/highlight" "^1.0.0" 13 | "@lezer/lr" "^1.0.0" 14 | "@nextjournal/lezer-clojure" "1.0.0" 15 | "w3c-keyname" "^2.2.4"}} 16 | -------------------------------------------------------------------------------- /src/nextjournal/clojure_mode/live_grammar.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.live-grammar 2 | (:require ["@lezer/generator" :as lg] 3 | [shadow.resource :as rc] 4 | [nextjournal.clojure-mode.node :as n])) 5 | 6 | ;;for dev, it's useful to build the parser in the browser 7 | (def parser 8 | (lg/buildParser 9 | (rc/inline "./clojure.grammar") 10 | #js{:externalProp n/node-prop})) 11 | 12 | (comment 13 | (.parse parser "(def foo)") 14 | (.parse parser "(ns foo)") 15 | (.parse parser "(foo bar)") 16 | ) 17 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/selections.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.selections 2 | (:refer-clojure :exclude [range]) 3 | (:require ["@codemirror/state" :refer [EditorSelection]])) 4 | 5 | (defn range 6 | ([from to] (.range EditorSelection from to)) 7 | ([^js range] (.range EditorSelection (.-from range) (.-to range)))) 8 | (defn cursor [from] (.cursor EditorSelection from)) 9 | (defn create [ranges index] (.create EditorSelection ranges index)) 10 | (defn constrain [^js state from] (-> from (max 0) (min (.. state -doc -length)))) 11 | (defn eq? [^js sel1 sel2] 12 | (.eq sel1 sel2)) 13 | -------------------------------------------------------------------------------- /test/nextjournal/scratch.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.scratch 2 | (:require [nextjournal.clojure-mode.commands :as commands] 3 | [nextjournal.clojure-mode :as cm-clojure] 4 | [nextjournal.clojure-mode.extensions.eval-region :as eval-region] 5 | [nextjournal.clojure-mode.test-utils :as test-utils])) 6 | 7 | (comment 8 | (def extensions 9 | (.concat cm-clojure/default-extensions (eval-region/extension #js {}))) 10 | 11 | (def apply-f (partial test-utils/apply-f extensions)) 12 | 13 | (js/console.log "a ;; hello\n(|)") 14 | (js/console.log (apply-f (commands/slurp -1) "a ;; hello\n(|)"))) 15 | 16 | -------------------------------------------------------------------------------- /.bb/tasks.bb: -------------------------------------------------------------------------------- 1 | (ns tasks 2 | (:require 3 | [babashka.tasks :refer [shell]] 4 | [clojure.string :as str])) 5 | 6 | (defn watch-cljs [] 7 | (let [watch (requiring-resolve 'pod.babashka.fswatcher/watch) 8 | ret (watch "src-squint" 9 | (fn [{:keys [type path]}] 10 | (when (and (#{:write :write|chmod :create} type) 11 | (or (str/ends-with? path ".cljs") 12 | (str/ends-with? path ".cljc")) 13 | ;; emacs shit: 14 | (not (str/includes? path ".#"))) 15 | (shell {:continue true 16 | :err :inherit 17 | :std :inherit} "yarn squint compile --output-dir public/squint/js" path))) 18 | {:recursive true})] 19 | (println (str "Started watching: " ret)) 20 | @(promise))) 21 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/chars.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.chars 2 | (:require ["@codemirror/text" :as text])) 3 | 4 | (defn pair-lookup [char-pairs ^string char] 5 | (let [end (count char-pairs) 6 | ch (text/codePointAt char 0)] 7 | (loop [i 0] 8 | (cond (>= i end) (text/fromCodePoint (if (< ch 128) ch (inc ch))) 9 | (== ch (.charCodeAt char-pairs i)) (.charAt char-pairs (inc i)) 10 | :else (recur (+ i 2)))))) 11 | 12 | (defn backspace? [^number code] (== code 8)) 13 | 14 | (defn next-char [^js doc ^number pos] 15 | (let [^string next (.sliceString doc pos (+ pos 2))] 16 | (.slice next 0 (text/codePointSize (text/codePointAt next 0))))) 17 | 18 | (defn prev-char [^js doc ^number pos] 19 | (if (pos-int? pos) 20 | (.sliceString doc (dec pos) pos) 21 | "")) 22 | 23 | (def whitespace? (->> [" " \n \r ","] 24 | (map #(.charCodeAt ^string % 0)) 25 | (set))) 26 | 27 | (comment 28 | ;; is there a way to iterate from a position, character by character? 29 | (defn pos-when [doc dir pred] 30 | )) 31 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src-shared" "src"] 2 | :deps {applied-science/js-interop {:mvn/version "0.3.3"} 3 | org.clojure/clojure {:mvn/version "1.12.0"} 4 | org.clojure/clojurescript {:mvn/version "1.11.132"}} 5 | :aliases 6 | {:dev {:extra-paths ["test"] 7 | :extra-deps {thheller/shadow-cljs {:mvn/version "2.28.20"}}} 8 | 9 | :demo {:extra-paths ["demo/src" "demo/notebooks"] 10 | :jvm-opts ["-Dpolyglot.engine.WarnInterpreterOnly=false"] 11 | :extra-deps {org.babashka/sci {:mvn/version "0.3.5"} 12 | reagent/reagent {:mvn/version "1.1.1"} 13 | io.github.nextjournal/clerk {#_#_ :local/root "../clerk" 14 | :git/sha "2fbd6f08fea2f736faae2e0cc46c435dce8a23f0" 15 | #_#_:mvn/version "0.17.1102"} 16 | ;; clerk dev deps 17 | io.github.babashka/sci.configs {:git/sha "ec570cdfb2c627d0cd280a899cd915d0e89d8f7b"} 18 | io.github.nextjournal/viewers {:git/sha "f4a32b05ff4382a85a3dcf66cdec89c2389ac3c8" 19 | :exclusions [io.github.nextjournal/clojure-mode]}}}}} 20 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps {:aliases [:dev :demo]} 2 | :dev-http {8001 "public" 3 | 8002 "public/test" 4 | 8003 "public/livedoc"} 5 | :nrepl {:port 9000} 6 | :builds {:demo {:compiler-options {:output-feature-set :es8} 7 | :target :browser 8 | :output-dir "public/js" 9 | :asset-path "js" 10 | :modules {:main {:init-fn nextjournal.clojure-mode.demo/render}}} 11 | 12 | :livedoc {:compiler-options {:output-feature-set :es8} 13 | :target :browser 14 | :output-dir "public/livedoc/js" 15 | :asset-path "js" 16 | :modules {:main {:init-fn nextjournal.clojure-mode.demo.livedoc/render}}} 17 | 18 | :test {:compiler-options {:output-feature-set :es8} 19 | :target :browser-test 20 | :test-dir "public/test" 21 | :ns-regexp "-tests$"} 22 | 23 | :ci-test {:target :node-test 24 | :ns-regexp "-tests$" 25 | :output-dir "out" 26 | :output-to "out/node-tests.js" 27 | :compiler-options {:optimizations :simple} 28 | :closure-defines {nextjournal.clojure-mode.util/node-js? true} 29 | :js-options {:output-feature-set :es8}}}} 30 | -------------------------------------------------------------------------------- /demo/src/nextjournal/clojure_mode/demo/sci.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.demo.sci 2 | (:require ["@codemirror/view" :as view] 3 | [applied-science.js-interop :as j] 4 | [clojure.string :as str] 5 | [nextjournal.clerk.sci-viewer :as sv] 6 | [nextjournal.clojure-mode.extensions.eval-region :as eval-region] 7 | [sci.core :as sci])) 8 | 9 | (defn eval-string 10 | ([source] (eval-string @sv/!sci-ctx source)) 11 | ([ctx source] 12 | (when-some [code (not-empty (str/trim source))] 13 | (try {:result (sci/eval-string* ctx code)} 14 | (catch js/Error e 15 | {:error (str (.-message e))}))))) 16 | 17 | (j/defn eval-at-cursor* [on-result ^:js {:keys [state]}] 18 | (some->> (eval-region/cursor-node-string state) 19 | (eval-string) 20 | (on-result)) 21 | true) 22 | 23 | (j/defn eval-top-level* [on-result ^:js {:keys [state]}] 24 | (some->> (eval-region/top-level-string state) 25 | (eval-string) 26 | (on-result)) 27 | true) 28 | 29 | (j/defn eval-cell [on-result ^:js {:keys [state]}] 30 | (-> (.-doc state) 31 | (str) 32 | (eval-string) 33 | (on-result)) 34 | true) 35 | 36 | (defn keymap* [modifier] 37 | {:eval-cell 38 | [{:key "Alt-Enter" 39 | :doc "Evaluate cell"}] 40 | :eval-at-cursor 41 | [{:key (str modifier "-Enter") 42 | :doc "Evaluates form at cursor"}] 43 | :eval-top-level 44 | [{:key (str modifier "-Shift-Enter") 45 | :doc "Evaluates top-level form at cursor"}]}) 46 | 47 | (defn extension [{:keys [modifier 48 | on-result]}] 49 | (.of view/keymap 50 | (j/lit 51 | [{:key "Alt-Enter" 52 | :run (partial eval-cell on-result)} 53 | {:key (str modifier "-Enter") 54 | :shift (j/fn eval-top-level [result] 55 | (eval-top-level* on-result result)) 56 | :run (j/fn eval-at-cursor [result] 57 | (eval-at-cursor* on-result result))}]))) 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nextjournal/clojure-mode", 3 | "files": [ 4 | "dist" 5 | ], 6 | "version": "0.3.3", 7 | "license": "EPL-2.0", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/nextjournal/clojure-mode" 11 | }, 12 | "dependencies": { 13 | "@codemirror/autocomplete": "^6.0.2", 14 | "@codemirror/commands": "^6.0.0", 15 | "@codemirror/lang-markdown": "6.0.0", 16 | "@codemirror/language": "^6.1.0", 17 | "@codemirror/lint": "^6.0.0", 18 | "@codemirror/search": "^6.0.0", 19 | "@codemirror/state": "^6.0.1", 20 | "@codemirror/view": "^6.0.2", 21 | "@lezer/common": "^1.0.0", 22 | "@lezer/generator": "^1.0.0", 23 | "@lezer/highlight": "^1.0.0", 24 | "@lezer/lr": "^1.0.0", 25 | "@nextjournal/lezer-clojure": "1.0.0", 26 | "markdown-it-footnote": "^3.0.3", 27 | "squint-cljs": "0.8.129", 28 | "w3c-keyname": "^2.2.4" 29 | }, 30 | "comments": { 31 | "to run squint as a local dependency:": "bb yarn-install:squint-dev" 32 | }, 33 | "scripts": { 34 | "watch": "bb copy-viewer-css && shadow-cljs -A:demo watch demo livedoc test", 35 | "build": "shadow-cljs -A:demo release demo livedoc", 36 | "test": "shadow-cljs -A:dev release ci-test && node out/node-tests.js", 37 | "watch2": "git ls-files | entr yarn test", 38 | "vite:dev": "vite --open -l info --config vite.config.js public/squint", 39 | "vite:build": "yarn vite build --config vite.config.js public/squint" 40 | }, 41 | "devDependencies": { 42 | "@codemirror/lang-javascript": "^6.0.0", 43 | "@nextjournal/clojure-mode": "link:.", 44 | "@squint-cljs/macros": "0.1.0", 45 | "d3-require": "^1.2.4", 46 | "emoji-regex": "^10.0.0", 47 | "framer-motion": "^6.2.8", 48 | "katex": "^0.12.0", 49 | "markdown-it": "12.3.2", 50 | "markdown-it-block-image": "0.0.3", 51 | "markdown-it-sidenote": "https://github.com/gerwitz/markdown-it-sidenote#aa5de8ce3168b7d41cb33c3aed071a5f41ce0083", 52 | "markdown-it-texmath": "0.9.1", 53 | "markdown-it-toc-done-right": "4.2.0", 54 | "punycode": "2.1.1", 55 | "react": "^17.0.2", 56 | "react-dom": "^17.0.2", 57 | "rollup-plugin-analyzer": "^4.0.0", 58 | "shadow-cljs": "2.19.5", 59 | "vite": "^4.4.9" 60 | }, 61 | "exports": { 62 | ".": "./dist/nextjournal/clojure_mode.mjs", 63 | "./extensions/eval-region": "./dist/nextjournal/clojure_mode/extensions/eval_region.mjs", 64 | "./extensions/formatting": "./dist/nextjournal/clojure_mode/extensions/formatting.mjs" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/test_utils.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.test-utils 2 | (:require ["@codemirror/state" :as cm-state 3 | :refer [EditorState EditorSelection]] 4 | #?@(:squint [] 5 | :cljs [[applied-science.js-interop :as j]]) 6 | [clojure.string :as str]) 7 | (:require-macros [applied-science.js-interop :as j])) 8 | 9 | ;; (de)serialize cursors| and for testing 10 | 11 | (defn make-state [extensions doc] 12 | (let [[doc ranges] (->> (re-seq #"\||<[^>]*?>|[^<>|]+" doc) 13 | (reduce (fn [[^string doc ranges] match] 14 | (cond (= match "|") 15 | [doc (conj ranges (.cursor EditorSelection (count doc)))] 16 | 17 | (str/starts-with? match "<") 18 | [(str doc (subs match 1 (dec (count match)))) 19 | (conj ranges (.range EditorSelection 20 | (count doc) 21 | (+ (count doc) (- (count match) 2))))] 22 | :else 23 | [(str doc match) ranges])) ["" []]))] 24 | (.create EditorState 25 | #js{:doc doc 26 | :selection (if (seq ranges) 27 | (.create EditorSelection (to-array ranges)) 28 | js/undefined) 29 | :extensions (cond-> #js[(.. EditorState -allowMultipleSelections (of true))] 30 | extensions 31 | (j/push! extensions))}))) 32 | 33 | (defn state-str [^js state] 34 | (let [doc (str (.-doc state))] 35 | (->> (.. state -selection -ranges) 36 | reverse 37 | (reduce (j/fn [doc ^:js {:keys [empty from to]}] 38 | (if empty 39 | (str (subs doc 0 from) "|" (subs doc from)) 40 | (str (subs doc 0 from) "<" (subs doc from to) ">" (subs doc to)))) doc)))) 41 | 42 | (comment 43 | (-> (make-state #js[] "b|cac|") 44 | (state-str) 45 | (= "b|cac|")) 46 | 47 | ) 48 | 49 | (defn apply-cmd [extensions cmd doc] 50 | (let [state (make-state extensions doc) 51 | !tr (atom nil) 52 | _ (cmd #js{:state state 53 | :dispatch #(reset! !tr %)}) 54 | tr @!tr] 55 | (state-str (j/get tr :state)))) 56 | 57 | (defn apply-f [extensions cmd doc] 58 | {:pre [(array? extensions) 59 | (fn? cmd) 60 | (string? doc)]} 61 | (let [state (make-state extensions doc) 62 | tr (cmd state)] 63 | (state-str (if tr (.-state tr) state)))) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clojure/Script mode for [CodeMirror 6](https://codemirror.net/6/) 2 | 3 | Enabling a decent Clojure/Script editor experience in the browser. Built for and by [Nextjournal](https://nextjournal.com/). 4 | 5 | * **[🤹‍♀️ Live demo with in-browser eval](https://nextjournal.github.io/clojure-mode/)** 6 | * [🐢 Try it in Nextjournal](https://nextjournal.com/try/clojure?cm6=1) 7 | * [📦 Use it in your project](#-use-it-in-your-project) 8 | 9 | ## ✨ Features 10 | 11 | **⚡️ Lightning-fast thanks to [lezer incremental parsing](https://lezer.codemirror.net/)** 12 | * Try pasting [`clojure/core.clj`](https://raw.githubusercontent.com/clojure/clojure/master/src/clj/clojure/core.clj) into the [live demo](https://nextjournal.github.io/clojure-mode/). 13 | 14 | **🥤 Slurping & 🤮 Barfing** 15 | * Forward: Ctrl + / or Mod + + J / K 16 | * Backward: Ctrl + Alt + / 17 | 18 | **💗 Semantic Selections** 19 | * Expand/Contract: Alt + / or Mod + 1 / 2 20 | 21 | 🧙 **Prepared for evaluation** 22 | * At Cursor: Mod + 23 | * Top-level form: Mod + + 24 | * Cell: Alt + 25 | 26 | **🧹 Autoformatting** following [Tonsky’s Better Clojure Formatting](https://tonsky.me/blog/clojurefmt/) 27 | 28 | 🎹 **And lots of more useful [key bindings](https://nextjournal.github.io/clojure-mode/#keybindings)** 29 | 30 | ## 📦 Use it in your project 31 | 32 | ### Include it in your `deps.edn` 33 | 34 | ```clojure 35 | {:deps {io.github.nextjournal/clojure-mode {:git/sha ""}}} 36 | ``` 37 | 38 | ### Use it from [NPM](https://www.npmjs.com/package/@nextjournal/clojure-mode) 39 | 40 | ```js 41 | import { default_extensions, complete_keymap } from '@nextjournal/clojure-mode'; 42 | import { EditorView, drawSelection, keymap } from '@codemirror/view'; 43 | import { EditorState } from '@codemirror/state'; 44 | 45 | let extensions = [keymap.of(complete_keymap), 46 | ...default_extensions 47 | ]; 48 | 49 | let state = EditorState.create({doc: "... some clojure code...", 50 | extensions: extensions }); 51 | let editorElt = document.querySelector('#editor'); 52 | let editor = new EditorView({state: state, 53 | parent: editorElt, 54 | extensions: extensions }); 55 | ``` 56 | 57 | ## 🛠 Development Setup 58 | 59 | * Install JS dependencies: `yarn install` 60 | * Start dev server: `yarn watch` 61 | * Open the demo page at http://localhost:8002/ 62 | 63 | ## ⚖️ License 64 | 65 | Licensed under the EPL License, Copyright © 2020-present Nextjournal GmbH. 66 | 67 | See [LICENSE](https://github.com/nextjournal/clojure-mode/blob/master/LICENSE) for more information. 68 | -------------------------------------------------------------------------------- /src-squint/nextjournal/clojure_mode_tests/macros.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode-tests.macros 2 | (:require [clojure.walk :as walk])) 3 | 4 | (defmacro deftest [var-name & body] 5 | `(do 6 | (~'js* "// ~{}\n" ~var-name) 7 | ~@body)) 8 | 9 | (defmacro testing [_str & body] 10 | `(do ~@body)) 11 | 12 | (defn apply-template 13 | "For use in macros. argv is an argument list, as in defn. expr is 14 | a quoted expression using the symbols in argv. values is a sequence 15 | of values to be used for the arguments. 16 | 17 | apply-template will recursively replace argument symbols in expr 18 | with their corresponding values, returning a modified expr. 19 | 20 | Example: (apply-template '[x] '(+ x x) '[2]) 21 | ;=> (+ 2 2)" 22 | [argv expr values] 23 | (assert (vector? argv)) 24 | (assert (every? symbol? argv)) 25 | (walk/postwalk-replace (zipmap argv values) expr)) 26 | 27 | (defmacro do-template 28 | "Repeatedly copies expr (in a do block) for each group of arguments 29 | in values. values are automatically partitioned by the number of 30 | arguments in argv, an argument vector as in defn. 31 | 32 | Example: (macroexpand '(do-template [x y] (+ y x) 2 4 3 5)) 33 | ;=> (do (+ 4 2) (+ 5 3))" 34 | [argv expr & values] 35 | (let [c (count argv)] 36 | `(do ~@(map (fn [a] (apply-template argv expr a)) 37 | (partition c values))))) 38 | 39 | (defn ->assert [expr] 40 | (walk/postwalk (fn [expr] 41 | (if (and (seq? expr) 42 | (= '= (first expr))) 43 | (list* 'assert.equal (rest expr)) 44 | expr)) expr)) 45 | 46 | (defmacro are 47 | "Checks multiple assertions with a template expression. 48 | See clojure.template/do-template for an explanation of 49 | templates. 50 | 51 | Example: (are [x y] (= x y) 52 | 2 (+ 1 1) 53 | 4 (* 2 2)) 54 | Expands to: 55 | (do (is (= 2 (+ 1 1))) 56 | (is (= 4 (* 2 2)))) 57 | 58 | Note: This breaks some reporting features, such as line numbers." 59 | {:added "1.1"} 60 | [argv expr & args] 61 | (if (or 62 | ;; (are [] true) is meaningless but ok 63 | (and (empty? argv) (empty? args)) 64 | ;; Catch wrong number of args 65 | (and (pos? (count argv)) 66 | (pos? (count args)) 67 | (zero? (mod (count args) (count argv))))) 68 | (let [processed (map (fn [a] 69 | (apply-template argv (->assert expr) a)) 70 | (partition (count argv) args))] 71 | #_(println "======") 72 | #_(println args) 73 | #_(println processed) 74 | `(do ~@processed)) 75 | #?(:clj (throw (IllegalArgumentException. "The number of args doesn't match are's argv.")) 76 | :cljs (throw (js/Error "The number of args doesn't match are's argv."))))) 77 | 78 | (defmacro is 79 | [expr & _] 80 | (if (and (seq? expr) 81 | (= '= (first expr))) 82 | (list* 'assert.equal (rest expr)) 83 | expr)) 84 | -------------------------------------------------------------------------------- /squint-demo/index.mjs: -------------------------------------------------------------------------------- 1 | import { default_extensions, complete_keymap } from '@nextjournal/clojure-mode'; 2 | 3 | import { EditorView, drawSelection, keymap } from '@codemirror/view'; 4 | import { EditorState } from '@codemirror/state'; 5 | import { syntaxHighlighting, defaultHighlightStyle, foldGutter } from '@codemirror/language'; 6 | import { javascript } from '@codemirror/lang-javascript'; 7 | import { history, historyKeymap } from '@codemirror/commands'; 8 | 9 | let theme = EditorView.theme({ 10 | ".cm-content": {whitespace: "pre-wrap", 11 | passing: "10px 0", 12 | flex: "1 1 0"}, 13 | 14 | "&.cm-focused": {outline: "0 !important"}, 15 | ".cm-line": {"padding": "0 9px", 16 | "line-height": "1.6", 17 | "font-size": "16px", 18 | "font-family": "var(--code-font)"}, 19 | ".cm-matchingBracket": {"border-bottom": "1px solid var(--teal-color)", 20 | "color": "inherit"}, 21 | ".cm-gutters": {background: "transparent", 22 | border: "none"}, 23 | ".cm-gutterElement": {"margin-left": "5px"}, 24 | // only show cursor when focused 25 | ".cm-cursor": {visibility: "hidden"}, 26 | "&.cm-focused .cm-cursor": {visibility: "visible"} 27 | }); 28 | 29 | let extensions = [ history(), 30 | theme, 31 | foldGutter(), 32 | syntaxHighlighting(defaultHighlightStyle), 33 | drawSelection(), 34 | keymap.of(complete_keymap), 35 | keymap.of(historyKeymap), 36 | ...default_extensions 37 | ]; 38 | 39 | let state = EditorState.create({doc: `(comment 40 | (fizz-buzz 1) 41 | (fizz-buzz 3) 42 | (fizz-buzz 5) 43 | (fizz-buzz 15) 44 | (fizz-buzz 17) 45 | (fizz-buzz 42)) 46 | 47 | (defn fizz-buzz [n] 48 | (condp (fn [a b] (zero? (mod b a))) n 49 | 15 "fizzbuzz" 50 | 3 "fizz" 51 | 5 "buzz" 52 | n))`, 53 | extensions: extensions }); 54 | let editorElt = document.querySelector('#editor'); 55 | let editor = new EditorView({state: state, 56 | parent: editorElt}); 57 | 58 | new EditorView({ 59 | state: EditorState.create({ 60 | doc: `import { default_extensions, complete_keymap } from '@nextjournal/clojure-mode'; 61 | import { EditorView, drawSelection, keymap } from '@codemirror/view'; 62 | import { EditorState } from '@codemirror/state'; 63 | 64 | let extensions = [keymap.of(complete_keymap), 65 | ...default_extensions 66 | ]; 67 | 68 | let state = EditorState.create({doc: "... some clojure code...", 69 | extensions: extensions }); 70 | let editorElt = document.querySelector('#editor'); 71 | let editor = new EditorView({state: state, 72 | parent: editorElt, 73 | extensions: extensions });`, 74 | extensions: [ 75 | javascript(), 76 | foldGutter(), 77 | syntaxHighlighting(defaultHighlightStyle), 78 | EditorState.readOnly.of(true), 79 | theme]}), 80 | parent: document.querySelector('#js-usage')}); 81 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:min-bb-version "0.7.6" 2 | :paths [".bb"] 3 | :pods {org.babashka/fswatcher {:version "0.0.4"}} 4 | :tasks 5 | {:requires ([clojure.edn :as edn] 6 | [clojure.string :as str] 7 | [babashka.deps :as deps] 8 | [babashka.fs :as fs] 9 | [babashka.process :as p] 10 | [tasks :as t]) 11 | :init (do 12 | (defn viewer-css-path [] 13 | (let [cp (str/trim (with-out-str (deps/clojure ["-A:dev:demo" "-Spath"])))] 14 | (str/trim (:out (shell {:out :string} (str "bb -cp " cp " -e '(println (.getPath (clojure.java.io/resource \"css/viewer.css\")))'")))))) 15 | 16 | (defn get-paths [ext] 17 | (map str (fs/glob "." (str "{demo,src,collab}/**." (name ext)))))) 18 | 19 | copy-viewer-css {:doc "Copies viewer stylesheet to resources." 20 | :task (fs/copy (viewer-css-path) "resources/stylesheets/viewer.css" #{:replace-existing})} 21 | 22 | yarn-install (shell "yarn install") 23 | 24 | clean (let [paths (get-paths :mjs)] 25 | (println (apply str (interpose "\n" (cons "removing:" paths)))) 26 | (doseq [path paths] (fs/delete path))) 27 | 28 | #_#_compile {:doc "Use squintjs to compile all cljs files recursively" 29 | :depends [yarn-install] 30 | :task (shell {:std :inherit :err :inherit} 31 | (apply str (cons "yarn squint compile " 32 | (interpose " " (get-paths :cljs)))))} 33 | 34 | #_#_build {:doc "Compiles cljs files with squint and builds from mjs sources with vite" 35 | :depends [compile] 36 | :task (shell {:std :inherit :err :inherit} 37 | "yarn build")} 38 | 39 | watch-cljs (shell "yarn watch") 40 | 41 | squint:watch-cljs (shell "yarn run squint watch") 42 | 43 | yarn-install:squint-dev (do 44 | (shell "yarn add squint-cljs@link:../squint") 45 | (shell {:dir "node_modules/.bin"} "ln -sf" "../squint-cljs/node_cli.js" "squint")) 46 | 47 | vite:dev {:doc "Launches vite application" 48 | :depends [yarn-install] 49 | :task (shell "yarn vite:dev")} 50 | 51 | vite:build {:depends [yarn-install] 52 | :task (shell "yarn vite:build")} 53 | 54 | squint:build {:task (shell "yarn squint compile")} 55 | 56 | publish {:depends [squint:build] 57 | :doc "Publish to NPM, do `npm login` manually first to authenticate your shell." 58 | :task (shell "npm publish")} 59 | 60 | squint:demo:build {:task (do (shell {:dir "squint-demo"} "npm install") 61 | (shell {:dir "squint-demo"} "npx jspm link index.html -o index.html --cache no-cache") 62 | (fs/create-dirs "public/squint-cdn-demo") 63 | (fs/copy "squint-demo/index.mjs" "public/squint-cdn-demo/index.mjs" {:replace-existing true}) 64 | (fs/copy "squint-demo/index.html" "public/squint-cdn-demo/index.html" {:replace-existing true}))} 65 | 66 | -dev {:depends [vite:dev squint:watch-cljs watch-cljs]} 67 | 68 | dev {:doc "Compiles all cljs to mjs, runs vite in dev and starts a cherry watcher to recompile changed cljs. When run as `bb dev collab` also starts a Y.js collaboration server." 69 | ;; :depends [compile] 70 | :task (run '-dev {:parallel true})}}} 71 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: 🔧 Install java 9 | uses: actions/setup-java@v1 10 | with: 11 | java-version: '11.0.7' 12 | 13 | - name: 🔧 Install clojure 14 | uses: DeLaGuardo/setup-clojure@9.0 15 | with: 16 | cli: '1.10.3.943' 17 | 18 | - name: 🛎 Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: 🧶 Yarn Install 22 | run: yarn install 23 | 24 | - name: 🧪 Run CLJS tests 25 | run: yarn test 26 | 27 | - name: 🧪 Run squint tests 28 | run: | 29 | yarn squint compile 30 | node dist/nextjournal/clojure_mode_tests.mjs 31 | 32 | snapshot: 33 | name: Static App / Build 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: 🛎 Checkout 37 | uses: actions/checkout@v2 38 | 39 | - name: 🔐 Google Auth 40 | uses: google-github-actions/auth@v0 41 | with: 42 | credentials_json: ${{ secrets.GCLOUD_SERVICE_KEY }} 43 | 44 | - name: 🔧 Setup Google Cloud SDK 45 | uses: google-github-actions/setup-gcloud@v0.3.0 46 | 47 | - name: 🔧 Install java 48 | uses: actions/setup-java@v1 49 | with: 50 | java-version: '11.0.7' 51 | 52 | - name: 🔧 Install clojure 53 | uses: DeLaGuardo/setup-clojure@master 54 | with: 55 | cli: '1.10.3.943' 56 | 57 | - name: 🧶 Yarn Install 58 | run: yarn install 59 | 60 | - name: 🧶 Yarn Build 61 | run: | 62 | yarn build 63 | 64 | - name: 🧶 Squint Build 65 | run: | 66 | yarn squint compile 67 | yarn vite:build 68 | 69 | - name: 📠 Copy static build to bucket under SHA 70 | run: gsutil cp -r public gs://nextjournal-snapshots/clojure-mode/build/${{ github.sha }} 71 | 72 | - name: 📠 Copy static build to GitHub Pages 73 | if: ${{ github.ref == 'refs/heads/main' }} 74 | uses: JamesIves/github-pages-deploy-action@4.1.6 75 | with: 76 | branch: gh-pages # The branch the action should deploy to. 77 | folder: public # The folder the action should deploy. 78 | 79 | - name: ✅ Add link to Clojure Mode Demo 80 | uses: Sibz/github-status-action@v1 81 | with: 82 | authToken: ${{secrets.GITHUB_TOKEN}} 83 | context: 'CI / Static App / Clojure Mode' 84 | description: 'Ready' 85 | state: 'success' 86 | sha: ${{github.event.pull_request.head.sha || github.sha}} 87 | target_url: https://snapshots.nextjournal.com/clojure-mode/build/${{ github.sha }} 88 | 89 | - name: ✅ Add link to Clojure Mode Demo 90 | uses: Sibz/github-status-action@v1 91 | with: 92 | authToken: ${{secrets.GITHUB_TOKEN}} 93 | context: 'CI / Static App / Squint Build' 94 | description: 'Ready' 95 | state: 'success' 96 | sha: ${{github.event.pull_request.head.sha || github.sha}} 97 | target_url: https://snapshots.nextjournal.com/clojure-mode/build/${{ github.sha }}/squint/dist/ 98 | 99 | - name: ✅ Add link to LiveDoc Demo 100 | uses: Sibz/github-status-action@v1 101 | with: 102 | authToken: ${{secrets.GITHUB_TOKEN}} 103 | context: 'CI / Static App / LiveDoc' 104 | description: 'Ready' 105 | state: 'success' 106 | sha: ${{github.event.pull_request.head.sha || github.sha}} 107 | target_url: https://snapshots.nextjournal.com/clojure-mode/build/${{ github.sha }}/livedoc 108 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode 2 | (:require ["@codemirror/language" :as language :refer [LRLanguage LanguageSupport]] 3 | ["@lezer/highlight" :as highlight :refer [tags]] 4 | ["@nextjournal/lezer-clojure" :as lezer-clj] 5 | #?@(:squint [] 6 | :cljs [[applied-science.js-interop :as j]]) 7 | [nextjournal.clojure-mode.extensions.close-brackets :as close-brackets] 8 | [nextjournal.clojure-mode.extensions.formatting :as format] 9 | [nextjournal.clojure-mode.extensions.match-brackets :as match-brackets] 10 | [nextjournal.clojure-mode.extensions.selection-history :as sel-history] 11 | [nextjournal.clojure-mode.keymap :as keymap] 12 | [nextjournal.clojure-mode.node :as n]) 13 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 14 | 15 | (def fold-node-props 16 | (let [coll-span (fn [^js tree] #js{:from (inc (n/start tree)) 17 | :to (dec (n/end tree))})] 18 | (j/lit 19 | {:Vector coll-span 20 | :Map coll-span 21 | :List coll-span}))) 22 | 23 | (def style-tags 24 | (clj->js 25 | {:NS (.-keyword tags) 26 | :DefLike (.-keyword tags) 27 | "Operator/Symbol" (.-keyword tags) 28 | "VarName/Symbol" (.definition tags (.-variableName tags)) 29 | :Boolean (.-atom tags) 30 | "DocString/..." (.-emphasis tags) 31 | :Discard! (.-comment tags) 32 | :Number (.-number tags) 33 | :StringContent (.-string tags) 34 | ;; need to pass something, that returns " when being parsed as JSON 35 | ;; also #js doesn't treat this correctly, hence clj->js above 36 | "\"\\\"\"" (.-string tags) 37 | :Keyword (.-atom tags) 38 | :Nil (.-null tags) 39 | :LineComment (.-lineComment tags) 40 | :RegExp (.-regexp tags)})) 41 | 42 | (def parser lezer-clj/parser) 43 | 44 | (comment 45 | ;; to build a parser \""live" from a .grammar file, 46 | ;; rather than using a precompiled parser: 47 | (def parser 48 | (lg/buildParser 49 | (shadow.resource/inline "./clojure/clojure.grammar") 50 | #js{:externalProp n/node-prop}))) 51 | 52 | (defn syntax 53 | ([] (syntax parser)) 54 | ([^js parser] 55 | (.define LRLanguage 56 | #js {:parser (.configure parser #js {:props #js [format/props 57 | (.add language/foldNodeProp fold-node-props) 58 | (highlight/styleTags style-tags)]})}))) 59 | 60 | (def ^js/Array complete-keymap keymap/complete) 61 | (def ^js/Array builtin-keymap keymap/builtin) 62 | (def ^js/Array paredit-keymap keymap/paredit) 63 | 64 | (def default-extensions 65 | #js[(syntax lezer-clj/parser) 66 | (close-brackets/extension) 67 | (match-brackets/extension) 68 | (sel-history/extension) 69 | (format/ext-format-changed-lines)]) 70 | 71 | (def language-support 72 | "Eases embedding clojure mode into other languages (e.g. markdown). 73 | See https://codemirror.net/docs/ref/#language.LanguageSupport for motivations" 74 | (LanguageSupport. (syntax) (.. default-extensions (slice 1)))) 75 | 76 | (comment 77 | 78 | (let [state (test-utils/make-state #js[(syntax lezer-clj/parser)] "[] a")] 79 | (-> (n/tree state) 80 | (.resolve 2 1) ;; Symbol "a" 81 | .-prevSibling 82 | js/console.log)) 83 | 84 | (let [state (test-utils/make-state #js[(syntax lezer-clj/parser)] "\"\" :a")] 85 | (-> state 86 | n/tree 87 | (n/cursor 0 1) 88 | )) 89 | (let [state (test-utils/make-state #js[(syntax lezer-clj/parser)] "a\n\nb")] 90 | (-> state 91 | (n/tree 1 1) 92 | (->> (n/string state)) 93 | str 94 | )) 95 | (let [state (test-utils/make-state #js[(syntax lezer-clj/parser)] "([]| s)")] 96 | (-> state 97 | n/tree 98 | (n/terminal-cursor 3 1) 99 | )) 100 | 101 | (let [state (test-utils/make-state #js[(syntax lezer-clj/parser)] "(|")] 102 | (-> state 103 | (close-brackets/handle-close ")") 104 | (->> (n/string state))))) 105 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/extensions/selection_history.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.extensions.selection-history 2 | (:require ["@codemirror/state" :refer [StateField]] 3 | #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 4 | [nextjournal.clojure-mode.util :as u] 5 | [nextjournal.clojure-mode.selections :as sel] 6 | [nextjournal.clojure-mode.node :as n]) 7 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 8 | 9 | (def event-annotation (u/user-event-annotation "selectionhistory")) 10 | 11 | (defn second-last [^js arr] 12 | (when (> (.-length arr) 1) 13 | (aget arr (dec (.-length arr))))) 14 | 15 | (defn ser [selection] 16 | (-> (.toJSON ^js selection) 17 | (js->clj :keywordize-keys true) 18 | :ranges 19 | (->> (map (juxt :anchor :head))))) 20 | 21 | (defn something-selected? [^js selection] 22 | (-> selection .-ranges (->> (some #(not (.-empty ^js %)))))) 23 | 24 | (def selection-history-field 25 | "Stores selection history" 26 | (.define StateField 27 | #js{:create (fn [^js state] (list {:selection (.-selection state)})) 28 | :update 29 | (j/fn [stack ^:js {:as tr {:keys [selection]} :state :keys [docChanged]}] 30 | (let [previous-position 31 | (first (keep-indexed (fn [i x] 32 | (when (sel/eq? (:selection x) selection) 33 | i)) stack))] 34 | (cond 35 | 36 | ;; doc changed => clear log 37 | docChanged (list {:selection selection 38 | :event (u/get-user-event-annotation tr)}) 39 | 40 | ;; no selection => clear stack to current position 41 | (not (something-selected? selection)) 42 | (list {:selection selection 43 | :event (u/get-user-event-annotation tr)}) 44 | 45 | ;; selection found in stack => move there 46 | previous-position 47 | (let [[f & more] (drop previous-position stack)] 48 | (cons (assoc f :prev-event (:event (first stack))) more)) 49 | 50 | ;; transaction has selection => add to log 51 | :else 52 | (cons {:selection selection 53 | :event (u/get-user-event-annotation tr)} 54 | stack))))})) 55 | 56 | (defn extension [] selection-history-field) 57 | 58 | (defn stack [^js state] 59 | (.field state selection-history-field)) 60 | 61 | (j/defn grow-1 [state start end] 62 | (let [node (n/nearest-touching state end -1)] 63 | (->> (n/ancestors node) 64 | (mapcat (juxt n/inner-span identity)) ;; include inner-spans 65 | (cons node) 66 | (filter (j/fn [^:js {a-start :from a-end :to}] 67 | (and (<= a-start start) 68 | (>= a-end end) 69 | (not (and (== a-start start) 70 | (== a-end end)))))) 71 | first))) 72 | 73 | (defn selection-grow* [^js state] 74 | (u/update-ranges state 75 | #js{:annotations event-annotation} 76 | (j/fn [^:js {:as range :keys [from to empty]}] 77 | (if empty 78 | {:range (or (some->> (n/nearest-touching state from -1) 79 | (n/balanced-range state)) 80 | range)} 81 | {:range (or (some->> (grow-1 state from to) 82 | n/range) 83 | range)})))) 84 | 85 | (defn selection-return* [^js state] 86 | (if-let [selection (:selection (second (stack state)))] 87 | (.update state #js{:selection selection 88 | :annotations event-annotation}) 89 | (u/update-ranges state 90 | #js{:annotations event-annotation} 91 | (fn [^js range] {:cursor (.-from range)})))) 92 | -------------------------------------------------------------------------------- /public/livedoc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LiveDoc 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 33 | 34 | 35 | 36 | 37 | 38 | 77 | 78 | 79 | 80 |
81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/extensions/match_brackets.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.extensions.match-brackets 2 | (:require 3 | ["@codemirror/state" :refer [StateField]] 4 | ["@codemirror/view" :refer [EditorView 5 | Decoration]] 6 | #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 7 | [nextjournal.clojure-mode.node :as n] 8 | [nextjournal.clojure-mode.util :as u]) 9 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 10 | 11 | (def base-theme 12 | (->> 13 | (j/lit {:$matchingBracket {:color "#0b0"} 14 | :$nonmatchingBracket {:color "#a22"}}) 15 | (.baseTheme EditorView))) 16 | 17 | (def ^js matching-mark (.mark Decoration (j/obj :class "cm-matchingBracket"))) 18 | (def ^js nonmatching-mark (.mark Decoration (j/obj :class "cm-nonmatchingBracket"))) 19 | 20 | (defn mark-node [node ^js mark] 21 | (.range mark (n/start node) (n/end node))) 22 | 23 | (def state 24 | (->> 25 | (j/lit 26 | {:create (constantly (.-none Decoration)) 27 | :update (j/fn [deco ^:js {:as tr :keys [state docChanged selection]}] 28 | (if (or docChanged selection) 29 | (let [decos (->> (.. tr -state -selection -ranges) 30 | (reduce 31 | (j/fn [out ^:js {:keys [head empty]}] 32 | (or 33 | ;; a parsed bracket is found before/after cursor 34 | (when-let [bracket (and empty 35 | (->> [(n/tree state head -1) (n/tree state head 1)] 36 | (filter (some-fn n/start-edge? n/end-edge?)) 37 | first))] 38 | ;; try finding a matching bracket 39 | (if-let [other-bracket (cond 40 | 41 | ;; are we at starting position? 42 | (and (n/start-edge? bracket) 43 | (= (n/start bracket) 44 | (n/start (n/up bracket)))) 45 | ;; get end-bracket 46 | (-> bracket n/up n/down-last 47 | (u/guard #(= (n/name %) 48 | (n/closed-by bracket)))) 49 | 50 | ;; are we at ending position? 51 | (and (n/end-edge? bracket) 52 | (= (n/end bracket) 53 | (n/end (n/up bracket)))) 54 | ;; get start-bracket 55 | (-> bracket n/up n/down 56 | (u/guard #(= (n/name %) 57 | (n/opened-by bracket)))))] 58 | (conj out 59 | (mark-node bracket matching-mark) 60 | (mark-node other-bracket matching-mark)) 61 | (conj out (mark-node bracket nonmatching-mark)))) 62 | ;; lezer does not produce tokens for non-matching close-brackets 63 | ;; (we haven't entered a collection, so brackets are not valid tokens 64 | ;; and aren't parsed). So we need to check the string to see if an 65 | ;; unmatched bracket is sitting in front of the cursor. 66 | (when-let [_unparsed-bracket (and 67 | ;; skip this check if we're inside a string 68 | (not (-> (n/tree state head) (n/closest n/string?))) 69 | (->> (.. tr -state -doc (slice head (inc head)) toString) 70 | (contains? #{\] \) \}})))] 71 | (conj out (mark-node (n/from-to head (inc head)) nonmatching-mark))) 72 | out)) []))] 73 | (.set Decoration (into-array decos) true)) 74 | deco))}) 75 | (.define StateField))) 76 | 77 | (defn extension [] 78 | #js[base-theme 79 | state 80 | (.. EditorView -decorations (from state))]) 81 | -------------------------------------------------------------------------------- /src/nextjournal/clojure_mode/clojure.grammar: -------------------------------------------------------------------------------- 1 | @external prop prefixColl from "./props" 2 | @external prop coll from "./props" 3 | @external prop prefixEdge from "./props" 4 | @external prop sameEdge from "./props" 5 | @external prop prefixContainer from "./props" 6 | 7 | @top Program { expression* } 8 | 9 | @skip { whitespace | LineComment | Discard } 10 | 11 | expression { Boolean | Nil | Deref | Quote | SyntaxQuote | Unquote | UnquoteSplice | Symbol | Number | Keyword | List | Vector | Map | String | Character | Set | NamespacedMap | RegExp | Var | ReaderConditional | SymbolicValue | AnonymousFunction | Meta | TaggedLiteral | ConstructorCall } 12 | Discard { "#_" expression } 13 | @precedence { docString @left, operator @left, meta @right} 14 | 15 | listContents { 16 | defList { defLikeWithMeta varNameWithMeta (DocString expression+ | expression+)? } | 17 | nsList { nsWithMeta varNameWithMeta (DocString expression* | expression*) } | 18 | anyList { operatorWithMeta? expression* } 19 | } 20 | 21 | DocString { !docString String } 22 | List[coll] { "(" listContents ")" } 23 | Vector[coll] { "[" expression* "]" } 24 | Map[coll] { "{" expression* "}" } 25 | VarName { Symbol } 26 | 27 | @skip {} { 28 | ReaderTag { "#" readerTagIdent } 29 | ConstructorPrefix[prefixEdge] { "#" qualifiedJavaIdent } 30 | 31 | SymbolicValue { "##" ident } 32 | Set[prefixColl] { "#" Map } 33 | AnonymousFunction[prefixColl] { "#" List } 34 | 35 | KeywordPrefix[prefixEdge] { "#" keyword } 36 | NamespacedMap[prefixColl] { KeywordPrefix Map } 37 | 38 | RegExp[prefixColl] { "#" String } 39 | Var[prefixColl] { "#'" Symbol } 40 | ReaderConditional[prefixColl] { "#?" (List | Deref) } 41 | ReaderMetadata[prefixColl] { "#^" expression } 42 | Metadata[prefixColl] { "^" expression } 43 | String { '"' StringContent? '"' } 44 | } 45 | 46 | Meta[prefixContainer] { (Metadata | ReaderMetadata) !meta t } 47 | TaggedLiteral[prefixContainer] { ReaderTag t } 48 | 49 | // https://clojure.org/reference/reader#_deftype_defrecord_and_constructor_calls_version_1_3_and_later 50 | ConstructorCall[prefixContainer] { ConstructorPrefix (Map | Vector) } 51 | 52 | Deref[prefixColl] { "@" expression } 53 | Quote[prefixColl] { "'" expression } 54 | SyntaxQuote[prefixColl] { "`" expression } 55 | Unquote[prefixColl] { "~" expression } 56 | UnquoteSplice[prefixColl] { "~@" expression } 57 | operatorWithMeta { Operator | Meta } 58 | defLikeWithMeta { DefLike | Meta } 59 | nsWithMeta { NS | Meta } 60 | varNameWithMeta { VarName | Meta } 61 | 62 | Operator { !operator Symbol } 63 | 64 | @tokens { 65 | 66 | 67 | "[" 68 | "{" 69 | "(" 70 | 71 | "#"[prefixEdge] 72 | "##"[prefixEdge] 73 | "#'"[prefixEdge] 74 | "#?"[prefixEdge] 75 | "#^"[prefixEdge] 76 | "#_"[prefixEdge] 77 | 78 | '"'[sameEdge, closedBy='"', openedBy='"'] 79 | "'"[prefixEdge] 80 | "`"[prefixEdge] 81 | "~"[prefixEdge] 82 | "~@"[prefixEdge] 83 | "^"[prefixEdge] 84 | "@"[prefixEdge] 85 | 86 | 87 | "]" 88 | "}" 89 | ")" 90 | 91 | whitespace { (std.whitespace | ",")+ } 92 | 93 | LineComment { ";" ![\n]* } 94 | 95 | // https://docs.oracle.com/javase/specs/jls/se7/html/jls-3.html#jls-3.8 96 | // class or constructor names 97 | javaIdentStart { std.asciiLetter | "_" | "$" | $[\u{a1}-\u{10ff}] } 98 | javaIdentChar { javaIdentStart | std.digit } 99 | javaIdent { javaIdentStart javaIdentChar* } 100 | qualifiedJavaIdent { javaIdent ("." javaIdent)+ } 101 | 102 | // reader tags cannot contain dots 103 | readerTagIdentStart { std.asciiLetter | $[<>&%_=?!*+\-$\u{a1}-\u{10ff}] } 104 | readerTagIdentChar { readerTagIdentStart | "/" | std.digit } 105 | readerTagIdent { readerTagIdentStart readerTagIdentChar* } 106 | 107 | identStart { std.asciiLetter | $[<>&%._=?!*+\-$\u{a1}-\u{10ff}/] } 108 | identChar { identStart | std.digit | ":" | "'" | "#" | "/"} 109 | ident { identStart identChar* } 110 | Symbol { ident } 111 | 112 | keyword { ":" ":"? ident? } // the invalid token :: can also be considered as a keyword 113 | Keyword { keyword } 114 | 115 | Number { 116 | ("+" | "-")? (std.digit+ ("." std.digit* "M"?)? | "." std.digit+) (("e" | "E") ("+" | "-")? std.digit+ "M"?)? | 117 | ("+" | "-")? std.digit+ ("M" | "N") | 118 | ("+" | "-")? std.digit+ "/" std.digit+ | 119 | ("+" | "-")? "0x" (std.digit | $[a-fA-F])+ | 120 | "0b" $[01]+ | 121 | "0o" $[0-7]+ 122 | } 123 | @precedence { Number, qualifiedJavaIdent, readerTagIdent, Symbol } 124 | 125 | StringContent { 126 | (!["] | "\\" _)+ 127 | } 128 | 129 | unicodeChar { "u" $[0-9a-fA-F] $[0-9a-fA-F] $[0-9a-fA-F] $[0-9a-fA-F]} 130 | octalChar { "o" $[0-3]? $[0-7] $[0-7]? } 131 | specialChar { "newline" | "space" | "tab" | "formfeed" | "backspace" | "return" } 132 | singleChar { ![\n] } 133 | Character { "\\" ( octalChar | unicodeChar | singleChar | specialChar ) } 134 | 135 | } 136 | 137 | Boolean { @specialize } 138 | Nil { @specialize } 139 | DefLike[@dynamicPrecedence=1] { @extend } 140 | NS[@dynamicPrecedence=2] { @extend } 141 | 142 | @detectDelim 143 | -------------------------------------------------------------------------------- /demo/notebooks/livedoc.md: -------------------------------------------------------------------------------- 1 | # 👋 Hello LiveDoc 2 | 3 | LiveDoc is a cljs notebook editor powered by [CodeMirror Markdown language support](https://github.com/codemirror/lang-markdown) and [nextjournal clojure mode](https://nextjournal.github.io/clojure-mode). 4 | 5 | In this demo we're evaluating code in [Clerk](https://github.com/nextjournal/clerk)'s SCI context. In particular we're rendering _markdown_ cells in terms of Clerk's viewers. This allows e.g. to get inline $\LaTeX$ formulas as well as block ones 6 | 7 | $$\hat{f}(x) = \int_{-\infty}^{+\infty} f(t)\exp^{-2\pi i x t}dt$$ 8 | 9 | Here's some of Clerk's API in action 10 | 11 | ```clojure 12 | (v/vl {:width 650 :height 400 :mark "geoshape" 13 | :data {:url "https://vega.github.io/vega-datasets/data/us-10m.json" 14 | :format {:type "topojson" 15 | :feature "counties"}} 16 | :transform 17 | [{:lookup "id" 18 | :from {:data {:url "https://vega.github.io/vega-datasets/data/unemployment.tsv"} 19 | :key "id" :fields ["rate"]}}] 20 | :projection {:type "albersUsa"} 21 | :encoding {:color {:field "rate" :type "quantitative"}}}) 22 | ``` 23 | ## Usage 24 | 25 | Use livedoc `editor` function as a reagent component in your cljs application 26 | 27 | [nextjournal.clojure-mode.livedoc/editor opts] 28 | 29 | this puts together an instance of CodeMirror with markdown and clojure mixed language support with a set of extensions configurable via an `opts` map with keys: 30 | 31 | * `:doc` (required) a markdown string 32 | 33 | * `:render` a function taking a reagent state atom, returning hiccup. Such state holds a map with: 34 | * `:text` the block's text 35 | * `:type` with values `:code` or `:markdown` 36 | * `:selected?` 37 | 38 | * `:eval-fn!` will be called on selected block states when evaluation is triggered 39 | 40 | * `:tooltip` customises tooltip view 41 | 42 | * `:extensions` extra CodeMirror extensions to be added along livedoc ones 43 | 44 | * `:focus?` should editor acquire focus when loaded 45 | 46 | ## Keybindings 47 | 48 | * `ESC`: toggles edit-one / edit-all / preview & select block 49 | * `ALT`: pressed while in edit mode toggles a tooltip with eval-at-cursor results 50 | * Arrow keys move selection up/down 51 | * `CMD + Enter` : Evaluate selected cell or leave edit mode 52 | * `CMD + Shift + Enter`: Evaluates all cells 53 | 54 | ```clojure 55 | (def pie 56 | (v/plotly 57 | {:data [{:values [27 11 25 8 1 3 25] 58 | :labels ["US" "China" "European Union" "Russian Federation" 59 | "Brazil" "India" "Rest of World"] 60 | :text "CO2" 61 | :textposition "inside" 62 | :domain {:column 1} 63 | :hoverinfo "label+percent+name" 64 | :hole 0.4 65 | :type "pie"}] 66 | :layout {:showlegend false 67 | :width 200 68 | :height 200 69 | :annotations [{:font {:size 20} :showarrow false :x 0.5 :y 0.5 :text "CO2"}]} 70 | :config {:responsive true}})) 71 | 72 | (def contour 73 | (v/plotly {:data [{:z [[10 10.625 12.5 15.625 20] 74 | [5.625 6.25 8.125 11.25 15.625] 75 | [2.5 3.125 5.0 8.125 12.5] 76 | [0.625 1.25 3.125 6.25 10.625] 77 | [0 0.625 2.5 5.625 10]] 78 | :type "contour"}]})) 79 | 80 | (v/col 81 | ;; FIXME: can't use nested v/html 82 | (v/with-viewer :html [:h1 "Plots"]) 83 | (v/row pie contour)) 84 | ``` 85 | 86 | ## Extending the Evaluation Context 87 | 88 | The rendering of blocks and their evaluation is fully customizable, this makes it easy to bring your own SCI context. In this notebook Clerk's context is being augmented of some convenient helpers for loading and handling data in the notebook: 89 | 90 | * `livedoc/with-fetch` 91 | * `csv/parse` 92 | * `observable/Plot` 93 | 94 | ```clojure 95 | (livedoc/with-fetch "https://gist.githubusercontent.com/netj/8836201/raw/6f9306ad21398ea43cba4f7d537619d0e07d5ae3/iris.csv" 96 | (fn [text] 97 | (v/table (take 10 (map js->clj (csv/parse text)))))) 98 | ``` 99 | 100 | The following example is taken from this [Observable notebook](https://observablehq.com/@observablehq/plot) 101 | 102 | ```clojure 103 | (livedoc/with-fetch "https://raw.githubusercontent.com/flother/rio2016/master/athletes.csv" 104 | (fn [data] 105 | (.. observable/Plot 106 | (dot (csv/parse data) 107 | (j/obj :x "weight" :y "height" :stroke "sex")) 108 | plot))) 109 | ``` 110 | ```clojure 111 | (v/plotly {:data [{:y (shuffle (range -100 100))}]}) 112 | ``` 113 | 114 | ```clojure 115 | (defonce state (atom 0)) 116 | ``` 117 | ```clojure 118 | (defn the-answer 119 | "to all questions" 120 | [x] 121 | (inc x)) 122 | ``` 123 | ```clojure 124 | (swap! state inc) 125 | ``` 126 | ```clojure 127 | (v/html [:h2 (str "The Answer is: " (the-answer @state))]) 128 | ``` 129 | 130 | ## Todo 131 | - [ ] cannot click to move cursor in each editable section bottom lines (probably we need calls to `requestMeasure`) 132 | - [ ] scroll selected block into view when moving out of viewport 133 | - [ ] clicking on blocks not always results in an edit at the right place 134 | - [ ] avoid re-rendering _all_ previews when scrolling or clicking to edit one (probably connected to height computations) 135 | - [ ] use async SCI eval 136 | - [ ] don't eval code when rendering previews (but only when leaving edit mode) 137 | -------------------------------------------------------------------------------- /public/squint/js/demo.mjs: -------------------------------------------------------------------------------- 1 | import { default_extensions, complete_keymap } from '@nextjournal/clojure-mode'; 2 | import { extension as eval_ext, cursor_node_string, top_level_string } from '@nextjournal/clojure-mode/extensions/eval-region'; 3 | import { EditorView, drawSelection, keymap } from '@codemirror/view'; 4 | import { EditorState } from '@codemirror/state'; 5 | import { history, historyKeymap } from '@codemirror/commands'; 6 | import { syntaxHighlighting, defaultHighlightStyle, foldGutter } from '@codemirror/language'; 7 | import { compileStringEx } from 'squint-cljs'; 8 | 9 | let theme = EditorView.theme({ 10 | ".cm-content": {whitespace: "pre-wrap", 11 | passing: "10px 0", 12 | flex: "1 1 0"}, 13 | 14 | "&.cm-focused": {outline: "0 !important"}, 15 | ".cm-line": {"padding": "0 9px", 16 | "line-height": "1.6", 17 | "font-size": "16px", 18 | "font-family": "var(--code-font)"}, 19 | ".cm-matchingBracket": {"border-bottom": "1px solid var(--teal-color)", 20 | "color": "inherit"}, 21 | ".cm-gutters": {background: "transparent", 22 | border: "none"}, 23 | ".cm-gutterElement": {"margin-left": "5px"}, 24 | // only show cursor when focused 25 | ".cm-cursor": {visibility: "hidden"}, 26 | "&.cm-focused .cm-cursor": {visibility: "visible"} 27 | }); 28 | let compilerState = null; 29 | let evalCode = async function (code) { 30 | compilerState = compileStringEx(`(do ${code})`, {repl: true, 31 | context: 'return', 32 | "elide-exports": true}, compilerState) 33 | let js = compilerState.javascript; 34 | let result; 35 | try { 36 | result = {value: await eval(`(async function() { ${js} })()`)}; 37 | } 38 | catch (e) { 39 | result = {error: true, ex: e}; 40 | } 41 | if (result.error) { 42 | document.getElementById("result").innerText = result.ex; 43 | } else { 44 | document.getElementById("result").innerText = '' + JSONstringify(result.value); 45 | } 46 | } 47 | 48 | let evalCell = (opts) => { 49 | let code = opts.state.doc.toString(); 50 | evalCode(code); 51 | return true; 52 | } 53 | 54 | let evalToplevel = function (opts) { 55 | let state = opts.state; 56 | let code = top_level_string(state); 57 | evalCode(code); 58 | return true; 59 | } 60 | 61 | function JSONstringify(json) { 62 | json = JSON.stringify(json, function(key, value) { 63 | if (!value) return value; 64 | if (typeof value === 'string') return value; 65 | if (Array.isArray(value) || value.constructor === Object) return value; 66 | if (value[Symbol.iterator]) { 67 | return [...value]; 68 | } 69 | if (typeof value === 'object') { 70 | return `#object[${value.constructor.name}]`; 71 | } else { 72 | return value; 73 | } 74 | }); 75 | return json; 76 | } 77 | 78 | let evalAtCursor = function (opts) { 79 | let state = opts.state; 80 | let code = cursor_node_string(state); 81 | evalCode(code); 82 | return true; 83 | } 84 | 85 | let squintExtension = ( opts ) => { 86 | return keymap.of([{key: "Alt-Enter", run: evalCell}, 87 | {key: opts.modifier + "-Enter", 88 | run: evalAtCursor, 89 | shift: evalToplevel 90 | }])} 91 | 92 | 93 | let extensions = [ history(), theme, foldGutter(), 94 | syntaxHighlighting(defaultHighlightStyle), 95 | drawSelection(), 96 | keymap.of(complete_keymap), 97 | keymap.of(historyKeymap), 98 | ...default_extensions, 99 | eval_ext({modifier: "Meta"}), 100 | squintExtension({modifier: "Meta"}) 101 | ]; 102 | 103 | let doc = `(comment 104 | (fizz-buzz 1) 105 | (fizz-buzz 3) 106 | (fizz-buzz 5) 107 | (fizz-buzz 15) 108 | (fizz-buzz 17) 109 | (fizz-buzz 42)) 110 | 111 | (defn fizz-buzz [n] 112 | (condp (fn [a b] (zero? (mod b a))) n 113 | 15 "fizzbuzz" 114 | 3 "fizz" 115 | 5 "buzz" 116 | n)) 117 | 118 | (require '["https://esm.sh/canvas-confetti@1.6.0$default" :as confetti]) 119 | 120 | (do 121 | (js-await (confetti)) 122 | (+ 1 2 3)) 123 | ` ; 124 | 125 | // doc = `(do #_#_1 (+ 1 2 3) )` 126 | 127 | evalCode(doc); 128 | 129 | let state = EditorState.create( {doc: doc, 130 | extensions: extensions }); 131 | 132 | let editorElt = document.querySelector('#editor'); 133 | let editor = new EditorView({state: state, 134 | parent: editorElt, 135 | extensions: extensions }) 136 | globalThis.editor = editor; 137 | 138 | let keys = {"ArrowUp": "↑", 139 | "ArrowDown": "↓", 140 | "ArrowRight": "→", 141 | "ArrowLeft": "←", 142 | "Mod": "Ctrl"} 143 | 144 | let macKeys = {"Alt": "⌥", 145 | "Shift": "⇧", 146 | "Enter": "⏎", 147 | "Ctrl": "⌃", 148 | "Mod": "⌘"} 149 | 150 | let mac; 151 | 152 | if (/^(Mac)|(iPhone)|(iPad)|(iPod)$/.test(window.navigator.platform.substring(0,3))) { 153 | mac = true; 154 | Object.assign(keys, macKeys); 155 | } 156 | 157 | document.querySelectorAll(".mod,.alt,.ctrl").forEach(node => { 158 | let k = node.innerHTML; 159 | let symbol = keys[k]; 160 | if (symbol) { 161 | node.innerHTML = symbol; 162 | } 163 | }); 164 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/keymap.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.keymap 2 | (:require ["@codemirror/commands" :as commands] 3 | [nextjournal.clojure-mode.commands :as cmd])) 4 | 5 | (defn update-some 6 | "Updates keys of map when key has value" 7 | [m updates] 8 | (reduce-kv (fn [m k f] 9 | (if-some [existing (get m k)] 10 | (assoc m k (get f existing)) 11 | (dissoc m k))) m updates)) 12 | 13 | ;; (de)serializing commands from keyword-id to function 14 | (defn serialize [command] (update-some command {:run cmd/reverse-index :shift cmd/reverse-index})) 15 | (defn deserialize [command] (update-some command {:run cmd/index :shift cmd/index})) 16 | 17 | 18 | (defn group 19 | "Returns a grouped map of bindings for a list of CodeMirror keymap entries" 20 | [commands] 21 | (->> commands 22 | (map serialize) 23 | (reduce (fn [out {:as cmd :keys [run]}] 24 | (update out run (fnil conj []) (dissoc cmd :run))) {}))) 25 | 26 | (defn ungroup 27 | "Returns a list of CodeMirror keymap entries for a grouped map of bindings" 28 | [commands] 29 | (->> commands 30 | (reduce-kv 31 | (fn [out k bindings] 32 | (into out (map #(deserialize (assoc % :run k)) bindings))) []) 33 | (clj->js))) 34 | 35 | (comment 36 | (->> [commands/standardKeymap #_historyKeymap] 37 | (mapcat #(js->clj % :keywordize-keys true)) 38 | group 39 | cljs.pprint/pprint)) 40 | 41 | (def builtin-keymap* 42 | {:cursorLineDown 43 | [{:key "ArrowDown", :shift :selectLineDown} 44 | {:mac "Ctrl-n", :shift :selectLineDown}], 45 | :selectAll [{:key "Mod-a"}], 46 | :cursorLineBoundaryForward 47 | [{:key "End", :shift :selectLineBoundaryForward}], 48 | :deleteCharBackward [{:key "Backspace"} {:mac "Ctrl-h"}], 49 | :cursorLineBoundaryBackward 50 | [{:key "Home", :shift :selectLineBoundaryBackward 51 | :mac "Ctrl-a"} 52 | {:mac "Cmd-ArrowLeft" :shift :selectLineBoundaryBackward}], 53 | :deleteCharForward [{:key "Delete"} {:mac "Ctrl-d"}], 54 | :cursorCharLeft 55 | [{:key "ArrowLeft", :shift :selectCharLeft} 56 | {:mac "Ctrl-b", :shift :selectCharLeft}], 57 | :cursorGroupBackward [{:mac "Alt-b", :shift :selectGroupBackward}], 58 | :cursorDocEnd 59 | [{:mac "Cmd-ArrowDown", :shift :selectDocEnd} 60 | {:key "Mod-End", :shift :selectDocEnd} 61 | {:mac "Alt->"}], 62 | :deleteGroupBackward 63 | [{:key "Mod-Backspace", :mac "Alt-Backspace"} 64 | {:mac "Ctrl-Alt-h"}], 65 | :deleteGroupForward 66 | [{:key "Mod-Delete", :mac "Ctrl-Alt-Backspace"} 67 | {:mac "Alt-Delete"} 68 | {:mac "Alt-d"}], 69 | :cursorPageDown 70 | [{:mac "Ctrl-ArrowDown", :shift :selectPageDown} 71 | {:key "PageDown", :shift :selectPageDown} 72 | {:mac "Ctrl-v"}], 73 | :cursorPageUp 74 | [{:mac "Ctrl-ArrowUp", :shift :selectPageUp} 75 | {:key "PageUp", :shift :selectPageUp} 76 | {:mac "Alt-v"}], 77 | :cursorLineEnd 78 | [{:mac "Cmd-ArrowRight"} 79 | {:mac "Ctrl-e", :shift :selectLineEnd}], 80 | :cursorGroupForward [{:mac "Alt-f", :shift :selectGroupForward}], 81 | :undoSelection [{:key "Mod-u", :preventDefault true}], 82 | :cursorCharRight 83 | [{:key "ArrowRight", :shift :selectCharRight} 84 | {:mac "Ctrl-f", :shift :selectCharRight}], 85 | :splitLine [{:mac "Ctrl-o"}], 86 | :transposeChars [{:mac "Ctrl-t"}], 87 | :cursorLineUp 88 | [{:key "ArrowUp", :shift :selectLineUp} 89 | {:mac "Ctrl-p", :shift :selectLineUp}], 90 | :cursorDocStart 91 | [{:mac "Cmd-ArrowUp", :shift :selectDocStart} 92 | {:key "Mod-Home", :shift :selectDocStart} 93 | {:mac "Alt-<"}]}) 94 | 95 | (def paredit-keymap* 96 | {:indent 97 | [{:key "Tab" 98 | :doc "Indent document (or selection)"}] 99 | :enter-and-indent 100 | [{:key "Enter" 101 | :doc "Insert newline and indent"}] 102 | :unwrap 103 | [{:key "Alt-s" 104 | :doc "Lift contents of collection into parent" 105 | :preventDefault true}] 106 | :kill 107 | [{:key "Ctrl-k" 108 | :doc "Remove all forms from cursor to end of line"}] 109 | :nav-left 110 | [{:key "Alt-ArrowLeft" 111 | :shift :nav-select-left 112 | :doc "Move cursor one unit to the left (shift: selects this region)" 113 | :preventDefault true}] 114 | :nav-right 115 | [{:key "Alt-ArrowRight" 116 | :shift :nav-select-right 117 | :doc "Move cursor one unit to the right (shift: selects this region)" 118 | :preventDefault true}] 119 | 120 | :slurp-forward 121 | [{:key "Ctrl-ArrowRight" 122 | :doc "Expand collection to include form to the right" 123 | :preventDefault true} 124 | {:key "Mod-Shift-k" :preventDefault true}] 125 | :slurp-backward 126 | [{:doc "Grow collection backwards by one form" 127 | :key "Ctrl-Alt-ArrowLeft" 128 | :preventDefault true}] 129 | 130 | :barf-forward 131 | [{:key "Ctrl-ArrowLeft" 132 | :doc "Shrink collection forwards by one form" 133 | :preventDefault true} 134 | {:key "Mod-Shift-j" :preventDefault true}] 135 | :barf-backward 136 | [{:doc "Shrink collection backwards by one form" 137 | :key "Ctrl-Alt-ArrowRight"}] 138 | 139 | :selection-grow 140 | [{:doc "Grow selections" 141 | :key "Alt-ArrowUp"} 142 | {:key "Mod-1"}] 143 | :selection-return 144 | [{:doc "Shrink selections" 145 | :key "Alt-ArrowDown"} 146 | {:key "Mod-2"}]}) 147 | 148 | (def builtin (ungroup builtin-keymap*)) 149 | (def paredit (ungroup paredit-keymap*)) 150 | (def complete (.concat paredit builtin)) 151 | 152 | (comment 153 | (ungroup default-keymap)) 154 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/util.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.util 2 | (:require #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 3 | ["@codemirror/state" :refer [EditorSelection 4 | StateEffect 5 | Transaction]] 6 | [nextjournal.clojure-mode.selections :as sel]) 7 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 8 | 9 | #?(:squint (def node-js? (some? js/globalThis.process)) 10 | :cljs (goog-define node-js? false)) 11 | 12 | (defn user-event-annotation [event-name] 13 | (.. Transaction -userEvent (of event-name))) 14 | 15 | (defn get-user-event-annotation [tr] 16 | (.annotation ^Transaction tr (.-userEvent Transaction))) 17 | 18 | (defn guard [x f] (when (f x) x)) 19 | 20 | (defn ^js from-to [p1 p2] 21 | (if (> p1 p2) #js{:from p2 :to p1} #js{:from p1 :to p2})) 22 | 23 | (defn dispatch-some 24 | "If passed a transaction, dispatch to view and return true to stop processing commands." 25 | [^js view tr] 26 | (if tr 27 | (do (.dispatch view tr) 28 | true) 29 | false)) 30 | 31 | (defn insertion 32 | "Returns a `change` that inserts string `s` at position `from` and moves cursor to end of insertion." 33 | ([from s] (insertion from from s)) 34 | ([from to ^string s] 35 | {:changes {:insert s 36 | :from from 37 | :to to} 38 | :cursor (+ from (count s))})) 39 | 40 | (defn deletion 41 | ([from] (deletion (max 0 (dec from)) from)) 42 | ([from to] 43 | (let [from (if (= from to) 44 | (max 0 (dec from)) 45 | from)] 46 | {:cursor from 47 | :changes {:from from :to to}}))) 48 | 49 | (defn line-content-at [state from] 50 | (-> state 51 | (j/call-in [:doc :lineAt] from) 52 | (j/get :text))) 53 | 54 | (defn map-cursor [^js original-range ^js state update-map] 55 | {:pre [(map? update-map)]} 56 | (let [{:keys [cursor/mapped 57 | cursor 58 | from-to 59 | range 60 | changes]} (guard update-map map?) 61 | change-desc (when changes (.changes state (-> changes 62 | clj->js)))] 63 | (cond-> #js{:range (or range 64 | (cond mapped (sel/cursor (.mapPos change-desc mapped)) 65 | cursor (sel/cursor cursor) 66 | from-to (sel/range (from-to 0) (from-to 1))) 67 | original-range)} 68 | change-desc (j/!set :changes change-desc)))) 69 | 70 | (defn update-ranges 71 | "Applies `f` to each range in `state` (see `changeByRange`)" 72 | ([state f] 73 | (update-ranges state nil f)) 74 | ([^js state tr-specs f ] 75 | (->> (fn [range] 76 | (or (when-some [result (f range)] 77 | (map-cursor range state result)) 78 | #js{:range range})) 79 | (.changeByRange state) 80 | (#(j/extend! % tr-specs)) 81 | (.update state)))) 82 | 83 | (defn dispatch-changes [^js state dispatch ^js changes] 84 | (when-not (.-empty changes) 85 | (dispatch (.update state #js{:changes changes})))) 86 | 87 | (defn update-lines 88 | [^js state f & [{:keys [from to spec] 89 | :or {from 0}}]] 90 | (let [iterator (.. state -doc iter)] 91 | (loop [result (.next iterator) 92 | changes #js[] 93 | from-pos from 94 | line-num 1] 95 | (j/let [^:js {:keys [done lineBreak ^string value]} result] 96 | (if (or done 97 | (> from to)) 98 | (.update state (j/extend! #js{:changes (.changes state changes)} spec)) 99 | (recur (.next iterator) 100 | (if-let [change (and (not lineBreak) (f from-pos value line-num))] 101 | (j/push! changes change) 102 | changes) 103 | (+ from-pos (count value)) 104 | (cond-> line-num lineBreak inc))))))) 105 | 106 | (defn update-selected-lines 107 | "`f` will be called for each selected line with args [line, changes-array, range] 108 | and should *mutate* changes-array" 109 | [^js state f] 110 | (let [at-line (atom -1) 111 | doc (.-doc state)] 112 | (->> (j/fn [^:js {:as range :keys [from to anchor head]}] 113 | (j/let [changes #js[]] 114 | (loop [^js line (.lineAt doc from)] 115 | (j/let [^:js {line-number :number line-to :to} line] 116 | (when (> (.-number line) @at-line) 117 | (reset! at-line line-number) 118 | (f line changes range)) 119 | (if-let [next-line (and (> to line-to) 120 | (guard (.lineAt doc (inc line-to)) 121 | #(> (.-number ^js %) line-number)))] 122 | (recur next-line) 123 | (let [^js change-set (.changes state changes)] 124 | #js{:changes changes 125 | :range (.range EditorSelection 126 | (.mapPos change-set anchor 1) 127 | (.mapPos change-set head 1))})))))) 128 | (.changeByRange state)))) 129 | 130 | 131 | (j/defn iter-changed-lines 132 | "`f` will be called for each changed line with args [line, changes-array] 133 | and should *mutate* changes-array. Selections will be mapped through the resulting changeset." 134 | [^:js {:as tr 135 | :keys [^js changes ^js effects selection] 136 | {:as ^js state :keys [^js doc]} :state} f] 137 | (let [at-line (atom -1) 138 | next-changes #js[] 139 | _ (.iterChanges 140 | changes 141 | (fn [_from-a _to-a from-b to-b _inserted] 142 | (j/let [^:js {:as line line-number :number line-to :to} (.lineAt doc from-b)] 143 | (loop [line line] 144 | (when (> line-number @at-line) 145 | (reset! at-line line-number) 146 | (f line next-changes)) 147 | (when-not (<= to-b line-to) 148 | (let [next-line (.lineAt doc (inc line-to))] 149 | (when (and next-line (> (.-number next-line) (.-number line))) 150 | (recur next-line)))))))) 151 | next-changeset (.changes state next-changes)] 152 | (if (seq next-changes) 153 | (-> (j/select-keys tr [:annotations 154 | :scrollIntoView 155 | :reconfigure]) 156 | (j/assoc! :changes (.compose changes next-changeset)) 157 | (cond-> 158 | selection 159 | (j/assoc! :selection (.. state -selection (map next-changeset))) 160 | effects 161 | (j/assoc! :effects (.mapEffects StateEffect effects next-changeset)))) 162 | tr))) 163 | 164 | (j/defn something-selected? [^:js {{:keys [ranges]} :selection}] 165 | (not (every? #(.-empty ^js %) ranges))) 166 | 167 | (j/defn range-str [state ^:js {:as _selection :keys [from to]}] 168 | (str (j/call-in state [:doc :slice] from to))) 169 | 170 | #_(prn (js/call-in #js {:a {:b {:c 3}}} [:a :b :c] inc)) 171 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/extensions/close_brackets.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.extensions.close-brackets 2 | (:require ["@codemirror/state" :refer [EditorState 3 | Prec]] 4 | ["@codemirror/view" :as view] 5 | #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 6 | [clojure.string :as str] 7 | [nextjournal.clojure-mode.node :as n] 8 | [nextjournal.clojure-mode.util :as u :refer [from-to]]) 9 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 10 | 11 | (defn in-string? [state pos] 12 | (contains? #{"StringContent" "String"} (n/name (n/tree state pos)))) 13 | 14 | (defn escaped? [state pos] 15 | (= \\ (.. state -doc (slice (max 0 (dec pos)) pos) toString))) 16 | 17 | (defn backspace-backoff [state from to] 18 | #_(js/console.log (some-> (n/|node state from) (u/guard n/line-comment?))) 19 | (if 20 | ;; handle line-comments (backspace should not drag forms up into line comments) 21 | (and 22 | ;; we are directly in right of a line-comment 23 | (some-> (n/node| state (dec from)) (u/guard n/line-comment?)) 24 | ;; current line is blank or we're left of a line-comment 25 | (not (or (str/blank? (u/line-content-at state from)) 26 | (some-> (n/|node state from) (u/guard n/line-comment?))))) 27 | {:cursor (dec from)} 28 | (u/deletion from to))) 29 | 30 | (j/defn handle-backspace 31 | "- skips over closing brackets 32 | - when deleting an opening bracket of an empty list, removes both brackets" 33 | [^:js {:as ^EditorState state}] 34 | (when-not (and (= 1 (.. state -selection -ranges -length)) 35 | (let [^js range (j/get-in state [:selection :ranges 0])] 36 | (and (.-empty range) (= 0 (.-from range))))) 37 | (u/update-ranges state 38 | #js{:annotations (u/user-event-annotation "delete")} 39 | (j/fn [^:js {:as _range :keys [head empty anchor]}] 40 | (j/let [^:js {:as _range from :from to :to} (from-to head anchor) 41 | ^js node| (.resolveInner (n/tree state) from -1) ;; node immediately to the left of cursor 42 | ^js parent (.-parent node|)] 43 | (cond 44 | 45 | (or (not empty) ;; selection 46 | (= "StringContent" (n/name (n/tree state from -1))) ;; inside a string 47 | (and parent (not (n/balanced? parent)) (n/left-edge? node|))) ;; unbalanced left-paren 48 | (u/deletion from to) 49 | 50 | ;; entering right edge of collection - skip 51 | (and (n/right-edge? node|) (== from (n/end parent))) 52 | {:cursor (dec from)} 53 | 54 | ;; inside left edge of collection - remove or stop 55 | (and (or (n/start-edge? node|) 56 | (n/same-edge? node|)) (== (n/start node|) (n/start parent))) 57 | (if (n/empty? (n/up node|)) 58 | ;; remove empty collection 59 | {:cursor (n/start parent) 60 | :changes [(from-to (n/start parent) (n/end parent))]} 61 | ;; stop cursor at inner-left of collection 62 | {:cursor from}) 63 | 64 | :else (backspace-backoff state from to))))))) 65 | 66 | (def coll-pairs (fn [x] 67 | (get {"(" ")" 68 | "[" "]" 69 | "{" "}" 70 | \" \"} x))) 71 | 72 | (defn handle-open [^EditorState state ^string open] 73 | (let [^string close (coll-pairs open)] 74 | (u/update-ranges state 75 | #js{:annotations (u/user-event-annotation "input")} 76 | (j/fn [^:js {:keys [from to head anchor empty]}] 77 | (cond 78 | (in-string? state from) 79 | (if (= \" open) 80 | (u/insertion head "\\\"") 81 | (u/insertion from to open)) 82 | ;; allow typing escaped bracket 83 | (escaped? state from) 84 | (u/insertion from to open) 85 | :else 86 | (if empty 87 | {:changes {:insert (str open close) 88 | :from head} 89 | :cursor (+ head (count open))} 90 | ;; wrap selections with brackets 91 | {:changes [{:insert open :from from} 92 | {:insert close :from to}] 93 | :from-to [(+ anchor (count open)) (+ head (count open))]})))))) 94 | 95 | (defn handle-close [state key-name] 96 | (u/update-ranges state 97 | #js{:annotations (u/user-event-annotation "input")} 98 | (j/fn [^:js {:as _range :keys [empty head from to]}] 99 | (if (or (in-string? state from) 100 | (escaped? state from)) 101 | (u/insertion from to key-name) 102 | (when empty 103 | (or 104 | ;; close unbalanced (open) collection 105 | (let [unbalanced (some-> 106 | (n/tree state head -1) 107 | (n/ancestors) 108 | (->> (filter (every-pred n/coll? (complement n/balanced?)))) 109 | first) 110 | closing (some-> unbalanced n/down n/closed-by) 111 | pos (some-> unbalanced n/end)] 112 | (when (and closing (= closing key-name)) 113 | {:changes {:from pos 114 | :insert closing} 115 | :cursor (inc pos)})) 116 | 117 | ;; jump to next closing bracket 118 | (when-let [close-node-end 119 | (when-let [^js cursor (-> state n/tree 120 | (n/terminal-cursor head 1))] 121 | (loop [] 122 | (if (n/right-edge-type? (.-type cursor)) 123 | (n/end cursor) 124 | (when (.next cursor) 125 | (recur)))))] 126 | {:cursor close-node-end}) 127 | ;; no-op 128 | {:cursor head} 129 | #_(u/insertion head key-name))))))) 130 | 131 | (j/defn handle-backspace-cmd [^:js {:as view :keys [state]}] 132 | (u/dispatch-some view (handle-backspace state))) 133 | 134 | (defn handle-open-cmd [key-name] 135 | (j/fn [^:js {:as view :keys [state]}] 136 | (u/dispatch-some view (handle-open state key-name)))) 137 | 138 | (defn handle-close-cmd [key-name] 139 | (j/fn [^:js {:as view :keys [state]}] 140 | (u/dispatch-some view (handle-close state key-name)))) 141 | 142 | (defn guard-scope 143 | "Command -> Command 144 | 145 | Guards command for it to be triggered from within the right scope, does nothing and propagates key otherwise" 146 | [cmd] 147 | (j/fn [^:js {:as view :keys [state]}] 148 | (if (or (n/embedded? state) (n/within-program? state)) 149 | (cmd view) 150 | false))) 151 | 152 | (defn extension [] 153 | (.high Prec 154 | (.of view/keymap 155 | (j/lit 156 | [{:key "Backspace" 157 | :run (guard-scope 158 | (j/fn [^:js {:as view :keys [state]}] 159 | (u/dispatch-some view (handle-backspace state))))} 160 | {:key "(" :run (guard-scope (handle-open-cmd "("))} 161 | {:key "[" :run (guard-scope (handle-open-cmd "["))} 162 | {:key "{" :run (guard-scope (handle-open-cmd "{"))} 163 | {:key \" :run (guard-scope (handle-open-cmd \"))} 164 | {:key \) :run (guard-scope (handle-close-cmd \)))} 165 | {:key \] :run (guard-scope (handle-close-cmd \]))} 166 | {:key \} :run (guard-scope (handle-close-cmd \}))}])))) 167 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/extensions/eval_region.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.extensions.eval-region 2 | (:require 3 | ["@codemirror/state" :as state :refer [StateEffect StateField]] 4 | ["@codemirror/view" :as view :refer [EditorView Decoration keymap]] 5 | ["w3c-keyname" :refer [keyName]] 6 | #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 7 | [nextjournal.clojure-mode.util :as u] 8 | [nextjournal.clojure-mode.node :as n] 9 | [clojure.string :as str]) 10 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 11 | 12 | (defn uppermost-edge-here 13 | "Returns node or its highest ancestor that starts or ends at the cursor position." 14 | [pos node] 15 | (let [node (or (->> (iterate n/up node) 16 | (take-while (every-pred (complement n/top?) 17 | #(or (= pos (n/end %) (n/end node)) 18 | (= pos (n/start %) (n/start node))))) 19 | (last)) 20 | node)] 21 | (if (= "Discard" (n/name node)) 22 | (last (n/children node)) 23 | node))) 24 | 25 | (defn main-selection [state] 26 | (-> 27 | (j/call-in state [:selection :asSingle]) 28 | (j/get-in [:ranges 0]))) 29 | 30 | (defn node-at-cursor 31 | ([state] (node-at-cursor state (j/get (main-selection state) :from))) 32 | ([^js state from] 33 | (some->> (n/nearest-touching state from -1) 34 | (#(when (or (n/terminal-type? (n/type %)) 35 | (<= (n/start %) from) 36 | (<= (n/end %) from)) 37 | (cond-> % 38 | (or 39 | (n/top? %) 40 | (and (not (n/terminal-type? (n/type %))) 41 | (< (n/start %) from (n/end %)))) 42 | (-> (n/children from -1) first)))) 43 | (uppermost-edge-here from) 44 | (n/balanced-range state)))) 45 | 46 | (defn top-level-node [state] 47 | (->> (n/nearest-touching state (j/get (main-selection state) :from) -1) 48 | (iterate n/up) 49 | (take-while (every-pred identity (complement n/top?))) 50 | last)) 51 | 52 | ;; Modifier field 53 | (defonce modifier-effect (.define StateEffect)) 54 | 55 | (defonce modifier-field 56 | (.define StateField 57 | (j/lit {:create (constantly {}) 58 | :update (fn [value ^js tr] 59 | (or (some-> (first (filter #(.is ^js % modifier-effect) (.-effects tr))) 60 | (j/get :value)) 61 | value))}))) 62 | 63 | (defn get-modifier-field [^js state] (.field state modifier-field)) 64 | 65 | (j/defn set-modifier-field! [^:js {:as _view :keys [dispatch]} value] 66 | (dispatch #js{:effects (.of modifier-effect value) 67 | :userEvent "evalregion"})) 68 | 69 | (j/defn mark [spec ^:js {:keys [from to]}] 70 | (-> (.mark Decoration spec) 71 | (.range from to))) 72 | 73 | (defn single-mark [spec range] 74 | (.set Decoration #js[(mark spec range)])) 75 | 76 | (defonce mark-spec (j/lit {:attributes {:style "background-color: rgba(0, 243, 255, 0.14);"}})) 77 | (defonce mark-spec-highlight (j/lit {:attributes {:style "background-color: rgba(0, 243, 255, 0.35);"}})) 78 | 79 | (defn cursor-range [^js state] 80 | (if (.. state -selection -main -empty) 81 | (node-at-cursor state) 82 | (.. state -selection -main))) 83 | 84 | (defonce region-field 85 | (.define StateField 86 | (j/lit 87 | {:create (constantly (.-none Decoration)) 88 | :update (j/fn [_value ^:js {:keys [state]}] 89 | (let [{:as field :strs [Shift Enter modifier]} (get-modifier-field state) 90 | modifier-pressed? (get field modifier) 91 | spec (if Enter mark-spec-highlight mark-spec)] 92 | (if-some [range (when (or (n/embedded? state) (n/within-program? state)) 93 | (cond (and modifier-pressed? Shift) (top-level-node state) 94 | modifier-pressed? (or (u/guard (main-selection state) #(not (j/get % :empty))) 95 | (cursor-range state))))] 96 | (single-mark spec range) 97 | (.-none Decoration))))}))) 98 | 99 | 100 | (defn get-region-field [^js state] (.field state region-field)) 101 | 102 | (defn current-range [^js state] 103 | (or (some-> (get-region-field state) 104 | (j/call :iter) 105 | (u/guard #(j/get % :value))) 106 | (.. state -selection -main))) 107 | 108 | (defn modifier-extension 109 | "Maintains modifier-state-field, containing a map of { true}, including Enter." 110 | [modifier] 111 | (let [handle-enter (j/fn handle-enter [^:js {:as view :keys [state]}] 112 | (set-modifier-field! view (assoc (get-modifier-field state) "Enter" true)) 113 | nil) 114 | handle-key-event (j/fn [^:js {:as event :keys [altKey shiftKey metaKey controlKey type]} 115 | ^:js {:as view :keys [state]}] 116 | (let [prev (get-modifier-field state) 117 | next (cond-> {"modifier" modifier} 118 | altKey (assoc "Alt" true) 119 | shiftKey (assoc "Shift" true) 120 | metaKey (assoc "Meta" true) 121 | controlKey (assoc "Control" true) 122 | (and (= "keydown" type) 123 | (= "Enter" (keyName event))) 124 | (assoc "Enter" true))] 125 | (when (not= prev next) 126 | (set-modifier-field! view next)) 127 | false)) 128 | handle-backspace (j/fn [^:js {:as _view :keys [state dispatch]}] 129 | (j/let [^:js {:keys [from to]} (current-range state)] 130 | (when (not= from to) 131 | (dispatch (j/lit {:changes {:from from :to to :insert ""} 132 | :annotations (u/user-event-annotation "delete")}))) 133 | true))] 134 | #js[modifier-field 135 | (.of keymap 136 | (j/lit [{:key (str modifier "-Enter") 137 | :shift handle-enter 138 | :run handle-enter} 139 | {:key (str modifier "-Backspace") 140 | :run handle-backspace 141 | :shift handle-backspace}])) 142 | (.domEventHandlers view/EditorView 143 | #js{:keydown handle-key-event 144 | :keyup handle-key-event})])) 145 | 146 | (defn extension [{:keys [modifier] 147 | :or {modifier "Meta"}}] 148 | #js[(modifier-extension modifier) 149 | region-field 150 | (.. EditorView -decorations (from region-field))]) 151 | 152 | (defn cursor-node-string [^js state] 153 | (u/guard (some->> (node-at-cursor state) 154 | (u/range-str state)) 155 | (complement str/blank?))) 156 | 157 | (defn top-level-string [^js state] 158 | (u/guard (some->> (top-level-node state) 159 | (u/range-str state)) 160 | (complement str/blank?))) 161 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/extensions/formatting.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.extensions.formatting 2 | (:require ["@codemirror/language" :as language :refer [IndentContext]] 3 | ["@codemirror/state" :refer [EditorState]] 4 | #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 5 | [nextjournal.clojure-mode.util :as u] 6 | [nextjournal.clojure-mode.node :as n]) 7 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 8 | 9 | ;; CodeMirror references 10 | ;; IndentContext https://codemirror.net/6/docs/ref/#state.IndentContext 11 | ;; indentation facet: https://codemirror.net/6/docs/ref/#state.EditorState%5Eindentation 12 | ;; indentation commands: https://codemirror.net/6/docs/ref/#commands.indentSelection 13 | 14 | ;; Clojure formatting reference 15 | ;; https://tonsky.me/blog/clojurefmt/ 16 | 17 | (defn spaces [^js state n] 18 | (.indentString language state n)) 19 | 20 | (j/defn indent-node-props [^:js {type-name :name :as type}] 21 | (j/fn [^:js {:as ^js context :keys [node]}] 22 | (cond (= "Program" type-name) 23 | 0 24 | 25 | (n/coll-type? type) 26 | (cond-> (.column context 27 | (-> node n/down n/end)) 28 | ;; start at the inner-left edge of the coll. 29 | ;; if it's a list beginning with a symbol, add 1 space. 30 | (and (= "List" type-name) 31 | (contains? #{"Operator" 32 | "DefLike" 33 | "NS"} (some-> node n/down n/right n/name))) 34 | (+ 1)) 35 | :else -1))) 36 | 37 | (def props (.add language/indentNodeProp 38 | indent-node-props)) 39 | 40 | (defn get-indentation [^js context pos] 41 | (language/getIndentation (.-state context) pos)) 42 | 43 | (defn make-indent-context [state] 44 | (new IndentContext state)) 45 | 46 | ;; TODO: check if this is used at all 47 | (defn indent-all [^js state] 48 | (let [context (make-indent-context state)] 49 | (u/update-lines state 50 | (fn [from content] 51 | (let [current-indent (-> (.exec #"^\s*" content) 52 | ^js (aget 0) 53 | .-length) 54 | ^number indent (-> (get-indentation context from) 55 | (u/guard (complement neg?)))] 56 | (when indent 57 | (case (compare indent current-indent) 58 | 0 nil 59 | 1 #js{:from (+ from current-indent) 60 | :insert (spaces state (- indent ^number current-indent))} 61 | -1 #js{:from (+ from indent) 62 | :to (+ from current-indent)}))))))) 63 | 64 | (defn expected-space [n1 n2] 65 | ;; (prn :expected (map n/name [n1 n2])) 66 | (if 67 | (or 68 | (n/start-edge-type? n1) 69 | (n/prefix-edge-type? n1) 70 | (n/end-edge-type? n2) 71 | (n/same-edge-type? n2)) 72 | 0 73 | 1)) 74 | 75 | (defn space-changes [state from to] 76 | (let [nodes (->> (n/terminal-nodes state from to) 77 | (filter #(or (<= from (n/start %) to) 78 | (<= from (n/end %) to))) 79 | (reverse)) 80 | trim? (some-> (first nodes) n/end (< to))] 81 | (->> nodes 82 | (partition 2 1) 83 | (reduce (j/fn [out [^:js {n2 :type start2 :from} 84 | ^:js {n1 :type end1 :to}]] 85 | (let [expected (expected-space n1 n2) 86 | actual (- start2 end1)] 87 | (case (compare actual expected) 88 | 0 out 89 | 1 (j/push! out #js{:from (if (zero? expected) 90 | end1 91 | (inc end1)) 92 | :to start2}) 93 | -1 (j/push! out #js{:from end1 94 | :insert " "}) 95 | out))) 96 | 97 | (if trim? 98 | (j/lit [{:from (-> nodes first n/end) 99 | :to to}]) 100 | #js[]))))) 101 | 102 | (defn into-arr [^js arr items] 103 | (doseq [i items] (.push arr i)) 104 | arr) 105 | 106 | (defn format-line 107 | "Returns mutated `changes` array" 108 | [^js state 109 | indent-context 110 | from 111 | text 112 | _line-num 113 | changes 114 | format-spaces?] 115 | {:pre [(some? text)]} 116 | (let [current-indent (-> ^js (aget (.exec #"^\s*" text) 0) 117 | .-length) 118 | ^number indent (-> (get-indentation indent-context from) 119 | (u/guard (complement neg?))) 120 | indentation-change 121 | (when indent 122 | (case (compare indent current-indent) 123 | 0 nil 124 | 1 #js{:from (+ from current-indent) 125 | :insert (spaces state (- indent current-indent))} 126 | -1 #js{:from (+ from indent) 127 | :to (+ from current-indent)})) 128 | space-changes (when (and format-spaces? 129 | (or (n/embedded? state from) 130 | (n/within-program? state from))) 131 | (space-changes state 132 | (+ from current-indent) 133 | (+ from (count text))))] 134 | (cond-> changes 135 | space-changes (into-arr space-changes) 136 | indentation-change (j/push! indentation-change)))) 137 | 138 | (defn format-selection 139 | [^js state] 140 | (let [context (make-indent-context state)] 141 | (u/update-selected-lines state 142 | (j/fn [^:js {:as _line :keys [from text number]} ^js changes] 143 | (format-line state context from text number changes true))))) 144 | 145 | (defn format-all [state] 146 | (let [context (make-indent-context state)] 147 | (u/update-lines state 148 | (fn [^number from ^string text line-num] 149 | (format-line state context from text line-num #js[] true))))) 150 | 151 | (defn format-transaction [^js tr] 152 | (let [origin (u/get-user-event-annotation tr)] 153 | (if-some [changes 154 | (when (n/within-program? (.-startState tr)) 155 | (case origin 156 | ("input" "input.type" 157 | "delete" 158 | "keyboardselection" 159 | "pointerselection" "select.pointer" 160 | "cut" 161 | "noformat" 162 | "evalregion") nil 163 | "format-selections" (format-selection (.-state tr)) 164 | (when-not (.. tr -changes -empty) 165 | (let [state (.-state tr) 166 | context (make-indent-context state)] 167 | (u/iter-changed-lines tr 168 | (fn [^js line ^js changes] 169 | (format-line state context (.-from line) (.-text line) (.-number line) changes true)))))))] 170 | (do #_(js/console.log :changes changes) 171 | (.. tr -startState (update (j/assoc! changes :filter false)))) 172 | tr))) 173 | 174 | (defn format [state] 175 | (if (u/something-selected? state) 176 | (.update state (format-selection state)) 177 | (format-all state))) 178 | 179 | (defn prefix-all [prefix state] 180 | (u/update-lines state 181 | (fn [from _ _] #js{:from from :insert prefix}))) 182 | 183 | (defn ext-format-changed-lines [] (.. EditorState -transactionFilter (of format-transaction))) 184 | -------------------------------------------------------------------------------- /demo/src/nextjournal/clojure_mode/demo/livedoc.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.demo.livedoc 2 | (:require 3 | ["react" :as react] 4 | [applied-science.js-interop :as j] 5 | [clojure.string :as str] 6 | [nextjournal.clerk.sci-viewer :as sv] 7 | [nextjournal.clerk.viewer :as v] 8 | [nextjournal.clojure-mode.demo :as demo] 9 | [nextjournal.clojure-mode.demo.sci :as demo.sci] 10 | [nextjournal.livedoc :as livedoc] 11 | [nextjournal.ui.components.d3-require :as d3-require] 12 | [reagent.dom :as rdom] 13 | [shadow.resource :as rc] 14 | [reagent.core :as r] 15 | [sci.core :as sci])) 16 | 17 | (defn result-view [r] 18 | (when-some [{:keys [error result]} r] 19 | [:div.viewer-result.m-2 {:style {:font-family "var(--code-font)"}} 20 | (cond 21 | error [:div.red error] 22 | (react/isValidElement result) result 23 | result [sv/inspect-paginated result])])) 24 | 25 | (defn wrap-element [el] 26 | (v/with-viewer :html 27 | (r/with-let [refn (fn [parent] (when parent (.append parent el)))] 28 | [:div {:ref refn}]))) 29 | 30 | ;; ctx libs 31 | (defn inspect [data] 32 | (when-some [wrapped-value 33 | (when data 34 | (cond 35 | (v/wrapped-value? data) data 36 | (or (instance? js/SVGElement data) 37 | (instance? js/HTMLElement data)) 38 | (wrap-element data) 39 | 'else data))] 40 | [sv/inspect-paginated wrapped-value])) 41 | 42 | (defn with-fetch* [url handler] 43 | (r/with-let [data (r/atom nil)] 44 | ;; FIXME: promise chain trick to have handler called on response 45 | (.. (js/fetch url) (then #(.text %)) (then #(reset! data (handler %)))) 46 | (fn [] 47 | [inspect @data]))) 48 | 49 | (defn with-fetch [url handler] 50 | (r/as-element [with-fetch* url handler])) 51 | 52 | (defn ^:dev/after-load render [] 53 | ;; set viewer tailwind stylesheet 54 | (j/assoc! (js/document.getElementById "viewer-stylesheet") 55 | :innerHTML (rc/inline "stylesheets/viewer.css")) 56 | 57 | (rdom/render 58 | [d3-require/with {:package ["@observablehq/plot@0.5" "papaparse@5.3.2"]} 59 | (fn [^js lib] 60 | ;; d3-require merges modules into a single object 61 | 62 | (r/with-let [ctx (sci/merge-opts 63 | (sci/fork @sv/!sci-ctx) 64 | {:namespaces 65 | {'livedoc {'with-fetch with-fetch} 66 | 'observable {'Plot lib} 67 | 'csv {'parse (fn [data] (.. lib (parse data (j/obj :header true :dynamicTyping true)) -data))}}})] 68 | 69 | [:div.rounded-md.mb-0.text-sm.border.shadow-lg.bg-white 70 | [livedoc/editor {:focus? true 71 | :extensions [demo/theme] 72 | :tooltip (fn [text _editor-view] 73 | (let [tt-el (js/document.createElement "div")] 74 | (rdom/render [:div.p-3 [result-view (demo.sci/eval-string ctx text)]] tt-el) 75 | (j/obj :dom tt-el))) 76 | 77 | ;; each cell is assigned a `state` reagent atom 78 | :eval-fn! 79 | (fn [state] 80 | (swap! state (fn [{:as s :keys [text]}] 81 | (assoc s :result (demo.sci/eval-string ctx text))))) 82 | 83 | :render 84 | (fn [state] 85 | (fn [] 86 | (let [{:keys [text type selected?] r :result} @state] 87 | [:div.flex.flex-col.rounded.m-2 88 | {:class (when selected? "ring-4")} 89 | (case type 90 | :markdown 91 | [:div.p-3.rounded.border 92 | [sv/inspect-paginated (v/with-viewer :markdown (:text @state))]] 93 | 94 | :code 95 | [:<> 96 | [:div.p-2.rounded.border.bg-slate-100 97 | [sv/inspect-paginated (v/with-viewer :code text)]] 98 | [result-view r]])]))) 99 | :doc (rc/inline "livedoc.md")}]]))] (js/document.getElementById "livedoc-container")) 100 | 101 | 102 | 103 | ;; longer example 104 | #_ 105 | (-> (js/fetch "https://raw.githubusercontent.com/applied-science/js-interop/master/README.md") 106 | (.then #(.text %)) 107 | (.then #(-> % ;; literal fixes 108 | (str/replace "…some javascript object…" ":x 123") 109 | (str/replace "…" "'…") 110 | (str/replace "..." "'…") 111 | #_ (str/replace "default-value" "'default-value") 112 | (str/replace "default" "'default") 113 | (str/replace "! a 10)" "! (into-array [1 2 3]) 10)"))) 114 | (.then (fn [markdown-doc] 115 | ;; hack into Clerk's sci-viewer context 116 | (let [ctx' (sci.core/merge-opts 117 | (sci.core/fork @sv/!sci-ctx) 118 | ;; FIXME: a more sane approach to js-interop ctx fixes 119 | {:namespaces {'user {'obj (j/lit {:x {:y 1} :z 2 :a 1}) 120 | '.-someFunction :someFunction 121 | 'o (j/obj :someFunction (fn [x] (str "called with: " x))) 122 | '.-x :x '.-y :y '.-z :z '.-a :a 123 | 'some-seq [#js {:x 1 :y 2} #js {:x 3 :y 4}]} 124 | 'my.app {'.-x :x '.-y :y '.-a :a '.-b :b '.-c :c 125 | 'some-seq [#js {:x 1 :y 2} #js {:x 3 :y 4}]} 126 | 'cljs.core {'implements? (fn [c i] false) 127 | 'ISeq nil}}})] 128 | (rdom/render 129 | [:div 130 | [:div.text-lg.font-medium.mb-4 131 | [:em "Testing LiveDoc on somewhat larger texts like " [:a {:href "https://github.com/applied-science/js-interop"} "js-interop"] " README."]] 132 | [:div.rounded-md.mb-0.text-sm.monospace.border.shadow-lg.bg-white 133 | [livedoc/editor {:doc markdown-doc 134 | :extensions [demo/theme] 135 | :tooltip (fn [text _editor-view] 136 | (let [tt-el (js/document.createElement "div")] 137 | (rdom/render [:div.p-3 [eval-code-view text]] tt-el) 138 | (j/obj :dom tt-el))) 139 | 140 | :eval-fn! 141 | (fn [state] 142 | (when state 143 | (swap! state (fn [{:as s :keys [text]}] 144 | (assoc s :result (demo.sci/eval-string ctx' text)))))) 145 | 146 | :render 147 | (fn [state] 148 | (fn [] 149 | (let [{:keys [text type selected?] r :result} @state] 150 | [:div.flex.flex-col.rounded.m-2 151 | {:class (when selected? "ring-4")} 152 | (case type 153 | :markdown 154 | [:div.p-3.rounded.border 155 | [:div.max-w-prose 156 | [sv/inspect-paginated (v/with-viewer :markdown (:text @state))]]] 157 | 158 | :code 159 | [:<> 160 | [:div.p-2.rounded.border.bg-slate-200 161 | [sv/inspect-paginated (v/with-viewer :code text)]] 162 | (when-some [{:keys [error result]} r] 163 | [:div.viewer-result.m-2 164 | {:style {:font-family "var(--code-font)"}} 165 | (cond 166 | error [:div.red error] 167 | (react/isValidElement result) result 168 | result (sv/inspect-paginated result))])])])))}]]] 169 | (js/document.getElementById "livedoc-large-container"))))))) 170 | -------------------------------------------------------------------------------- /resources/stylesheets/viewer.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html { 7 | font-size: 18px; 8 | } 9 | @media (max-width: 600px) { 10 | html { 11 | font-size: 16px; 12 | } 13 | } 14 | .font-condensed { font-family: "Fira Sans Condensed", sans-serif; } 15 | .font-inter { font-family: "Inter", sans-serif; } 16 | body { 17 | @apply font-serif antialiased text-gray-900 sm:overscroll-y-none; 18 | } 19 | code, .code { 20 | @apply font-mono text-sm text-gray-900 bg-slate-50 px-0.5 py-px rounded dark:bg-gray-800; 21 | } 22 | code::before, code::after { @apply content-none !important; } 23 | h1, h3, h4, h5, h6 { 24 | @apply font-condensed font-bold mt-8 first:mt-0; 25 | } 26 | h2 { 27 | /*We cannot collapse margins due to nesting but we want to*/ 28 | /*keep the h2’s large margin visible*/ 29 | @apply font-condensed font-bold mt-8 first:mt-2; 30 | } 31 | h1 { @apply text-4xl; } 32 | h2 { @apply text-3xl; } 33 | h3 { @apply text-2xl; } 34 | 35 | button { @apply focus:outline-none; } 36 | strong { @apply font-bold; } 37 | em { @apply italic; } 38 | pre { @apply m-0 font-mono; } 39 | } 40 | 41 | /* Compatibility */ 42 | /* --------------------------------------------------------------- */ 43 | /* TODO: Verify which colors are in use and replace with Tailwind 44 | colors accordingly. Move Nj-specific styles out of here. */ 45 | 46 | :root { 47 | --teal-color: #31afd0; 48 | --dark-teal-color: #095960; 49 | --near-black-color: #2e2e2c; 50 | --red-color: #d64242; 51 | --dark-blue-color: #1f2937; 52 | --dark-blue-60-color: rgba(28, 42, 56, 0.6); 53 | --gray-panel-color: rgba(239, 241, 245, 1.000); 54 | --brand-color: var(--dark-blue-color); 55 | --link-color: #5046e4; 56 | --command-bar-selected-color: var(--teal-color); 57 | } 58 | 59 | .serif { @apply font-serif; } 60 | .sans-serif { @apply font-sans; } 61 | .monospace { @apply font-mono; } 62 | .inter { @apply font-inter; } 63 | 64 | .border-color-teal { border-color: var(--dark-teal-color); } 65 | .teal { color: var(--teal-color); } 66 | .bg-dark-blue { background: var(--dark-blue-color); } 67 | .bg-dark-blue-60 { background: rgba(28, 42, 56, 0.6); } 68 | .bg-gray-panel { background: var(--gray-panel-color); } 69 | .text-dark-blue { color: var(--dark-blue-color); } 70 | .text-dark-blue-60 { color: var(--dark-blue-60-color); } 71 | .border-dark-blue-30 { border-color: rgba(28, 42, 56, 0.6); } 72 | .text-brand { color: var(--dark-blue-color); } 73 | .bg-brand { background: var(--dark-blue-color); } 74 | .text-selected { color: white; } 75 | .red { color: var(--red-color); } 76 | 77 | /* Disclose Button */ 78 | /* --------------------------------------------------------------- */ 79 | 80 | .disclose { 81 | @apply content-none border-solid cursor-pointer inline-block relative mr-[3px] top-[-2px] transition-all; 82 | border-color: var(--near-black-color) transparent; 83 | border-width: 6px 4px 0; 84 | } 85 | .disclose:hover { 86 | border-color: var(--near-black-color) transparent; 87 | } 88 | .dark .disclose, 89 | .dark .disclose:hover { 90 | border-color: white transparent; 91 | } 92 | .disclose.collapsed { 93 | @apply rotate-[-90deg]; 94 | } 95 | 96 | /* Layout */ 97 | /* --------------------------------------------------------------- */ 98 | 99 | .page { 100 | @apply max-w-5xl mx-auto px-12 box-border flex-shrink-0; 101 | } 102 | .max-w-prose { @apply max-w-[46rem] !important; } 103 | .max-w-wide { @apply max-w-3xl !important; } 104 | 105 | /* List Styles */ 106 | /* --------------------------------------------------------------- */ 107 | 108 | .task-list-item + .task-list-item, 109 | .viewer-markdown ul ul { 110 | @apply mt-1 mb-0; 111 | } 112 | 113 | /* compact TOC */ 114 | .viewer-markdown .toc ul { 115 | list-style: none; 116 | @apply my-1; 117 | } 118 | 119 | /* Code Viewer */ 120 | /* --------------------------------------------------------------- */ 121 | 122 | .viewer-code { 123 | @apply font-mono bg-slate-100 rounded-sm text-sm mt-4 overflow-x-auto dark:bg-gray-800; 124 | } 125 | .viewer-code .cm-content { 126 | @apply py-4 px-8; 127 | } 128 | @media (min-width: 960px){ 129 | .viewer-notebook .viewer-code .cm-content { 130 | @apply py-4 pl-12; 131 | } 132 | } 133 | /* Don’t show focus outline when double-clicking cell in Safari */ 134 | .cm-scroller { @apply focus:outline-none; } 135 | 136 | /* Syntax Highlighting */ 137 | /* --------------------------------------------------------------- */ 138 | 139 | .inspected-value { @apply text-xs font-mono leading-[1.25rem]; } 140 | .cmt-strong, .cmt-heading { @apply font-bold; } 141 | .cmt-italic, .cmt-emphasis { @apply italic; } 142 | .cmt-strikethrough { @apply line-through; } 143 | .cmt-link { @apply underline; } 144 | .untyped-value { @apply whitespace-nowrap; } 145 | 146 | .cm-editor, .cmt-default, .viewer-result { 147 | @apply text-slate-800 dark:text-slate-300; 148 | } 149 | .cmt-keyword { 150 | @apply text-purple-800 dark:text-pink-400; 151 | } 152 | .cmt-atom, .cmt-bool, .cmt-url, .cmt-contentSeparator, .cmt-labelName { 153 | @apply text-blue-900 dark:text-blue-300; 154 | } 155 | .cmt-inserted, .cmt-literal { 156 | @apply text-emerald-700 dark:text-emerald-200; 157 | } 158 | .cmt-string, .cmt-deleted { 159 | @apply text-rose-700 dark:text-sky-300; 160 | } 161 | .cmt-italic.cmt-string { 162 | @apply dark:text-sky-200; 163 | } 164 | .cmt-regexp, .cmt-escape { 165 | @apply text-orange-500 dark:text-orange-300; 166 | } 167 | .cmt-variableName { 168 | @apply text-blue-800 dark:text-sky-300; 169 | } 170 | .cmt-typeName, .cmt-namespace { 171 | @apply text-emerald-600 dark:text-emerald-300; 172 | } 173 | .cmt-className { 174 | @apply text-teal-600 dark:text-teal-200; 175 | } 176 | .cmt-macroName { 177 | @apply text-teal-700 dark:text-teal-200; 178 | } 179 | .cmt-propertyName { 180 | @apply text-blue-700 dark:text-blue-200; 181 | } 182 | .cmt-comment { 183 | @apply text-slate-500 dark:text-slate-400; 184 | } 185 | .cmt-meta { 186 | @apply text-slate-600 dark:text-slate-400; 187 | } 188 | .cmt-invalid { 189 | @apply text-red-500 dark:text-red-300; 190 | } 191 | 192 | .result-data { 193 | @apply font-mono text-sm overflow-x-auto whitespace-nowrap leading-normal; 194 | } 195 | .result-data::-webkit-scrollbar, .path-nav::-webkit-scrollbar { 196 | @apply h-0; 197 | } 198 | .result-data-collapsed { 199 | @apply whitespace-nowrap; 200 | } 201 | .result-data-field { 202 | @apply ml-4 whitespace-nowrap; 203 | } 204 | .result-data-field-link{ 205 | @apply ml-4 whitespace-nowrap cursor-pointer; 206 | } 207 | .result-data-field-link:hover { 208 | @apply text-black bg-black/5; 209 | } 210 | .result-text-empty { 211 | color: rgba(0,0,0,.3); 212 | } 213 | .browsify-button:hover { 214 | box-shadow: -2px 0 0 2px #edf2f7; 215 | } 216 | 217 | /* Prose */ 218 | /* --------------------------------------------------------------- */ 219 | 220 | .viewer-notebook, 221 | .viewer-markdown { 222 | @apply prose 223 | dark:prose-invert 224 | prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline 225 | dark:prose-a:text-blue-300 226 | prose-p:mt-4 prose-p:leading-snug 227 | prose-ol:mt-4 prose-ol:mb-6 prose-ol:leading-snug 228 | prose-ul:mt-4 prose-ul:mb-6 prose-ul:leading-snug 229 | prose-blockquote:mt-4 prose-blockquote:leading-snug 230 | prose-hr:mt-6 prose-hr:border-t-2 prose-hr:border-solid prose-hr:border-slate-200 231 | prose-figure:mt-4 232 | prose-figcaption:mt-2 prose-figcaption:text-xs 233 | prose-headings:mb-4 234 | prose-table:mt-0 235 | prose-th:mb-0 236 | prose-img:my-0 237 | prose-code:font-medium prose-code:bg-slate-100 238 | max-w-none; 239 | } 240 | .viewer-markdown blockquote p:first-of-type:before, 241 | .viewer-markdown blockquote p:last-of-type:after { 242 | @apply content-none; 243 | } 244 | 245 | /* Images */ 246 | /* --------------------------------------------------------------- */ 247 | 248 | 249 | /* Todo Lists */ 250 | /* --------------------------------------------------------------- */ 251 | 252 | .contains-task-list { 253 | @apply pl-6 list-none; 254 | } 255 | .contains-task-list input[type="checkbox"] { 256 | @apply appearance-none h-4 w-4 rounded border border-slate-200 relative mr-[0.3rem] ml-[-1.5rem] top-[0.15rem]; 257 | } 258 | .contains-task-list input[type="checkbox"]:checked { 259 | @apply border-indigo-600 bg-indigo-600 bg-no-repeat bg-contain; 260 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 261 | } 262 | 263 | /* Markdown TOC */ 264 | /* --------------------------------------------------------------- */ 265 | 266 | .viewer-markdown .toc { @apply mt-4; } 267 | .viewer-markdown h1 + .toc { @apply mt-8; } 268 | 269 | .viewer-markdown .toc h1, 270 | .viewer-markdown .toc h2, 271 | .viewer-markdown .toc h3, 272 | .viewer-markdown .toc h4, 273 | .viewer-markdown .toc h5, 274 | .viewer-markdown .toc h6 { 275 | @apply text-base text-indigo-600 font-sans my-0; 276 | } 277 | .viewer-markdown .toc a { 278 | @apply text-indigo-600 font-normal no-underline hover:underline; 279 | } 280 | .viewer-markdown .toc li { @apply m-0; } 281 | .viewer-markdown .toc ul ul { @apply pl-4; } 282 | 283 | /* Notebook Spacing */ 284 | /* --------------------------------------------------------------- */ 285 | 286 | .viewer-notebook { @apply py-16; } 287 | #clerk-static-app .viewer-notebook { @apply pt-[0.8rem] pb-16; } 288 | .viewer-markdown *:first-child:not(.viewer-code):not(li):not(h2) { @apply mt-0; } 289 | .viewer + .viewer { @apply mt-6; } 290 | .viewer + .viewer-result { @apply mt-0; } 291 | .viewer-code + .viewer-result { @apply mt-3; } 292 | .viewer-markdown + .viewer-markdown { @apply mt-0; } 293 | 294 | /* Sidenotes */ 295 | /* --------------------------------------------------------------- */ 296 | 297 | .sidenote-ref { 298 | @apply top-[-3px] inline-flex justify-center items-center w-[18px] h-[18px] 299 | rounded-full bg-slate-100 border border-slate-300 hover:bg-slate-200 hover:border-slate-300 300 | m-0 ml-[4px] cursor-pointer; 301 | } 302 | .sidenote { 303 | @apply hidden float-left clear-both mx-[2.5%] my-4 text-xs relative w-[95%]; 304 | } 305 | .sidenote-ref.expanded + .sidenote { 306 | @apply block; 307 | } 308 | @media (min-width: 860px) { 309 | .sidenote-ref { 310 | @apply top-[-0.5em] w-auto h-auto inline border-0 bg-transparent m-0 pointer-events-none; 311 | } 312 | .sidenote sup { @apply inline; } 313 | .viewer-markdown .contains-sidenotes p { @apply max-w-[65%]; } 314 | .viewer-markdown p .sidenote { 315 | @apply mr-[-54%] mt-[0.2rem] w-1/2 float-right clear-right relative block; 316 | } 317 | } 318 | .viewer-code + .viewer:not(.viewer-markdown):not(.viewer-code):not(.viewer-code-folded), 319 | .viewer-code-folded + .viewer:not(.viewer-markdown):not(.viewer-code):not(.viewer-code-folded), 320 | .viewer-result + .viewer-result { 321 | @apply mt-2; 322 | } 323 | .viewer-result { 324 | @apply leading-tight; 325 | } 326 | @media (min-width: 768px) { 327 | .devcard-desc > div { 328 | @apply max-w-full m-0; 329 | } 330 | } 331 | 332 | /* Command Palette */ 333 | /* --------------------------------------------------------------- */ 334 | 335 | .nj-commands-input { 336 | @apply bg-transparent text-white; 337 | } 338 | .nj-context-menu-item:hover:not([disabled]) { 339 | @apply cursor-pointer; 340 | background-color: rgba(255,255,255,.14); 341 | } 342 | 343 | /* Devdocs */ 344 | /* --------------------------------------------------------------- */ 345 | 346 | .logo, .logo-white { 347 | @apply block indent-[-999em]; 348 | background: url(/images/nextjournal-logo.svg) center center no-repeat; 349 | } 350 | .devdocs-body { 351 | @apply font-inter; 352 | } 353 | 354 | /* Workarounds */ 355 | /* --------------------------------------------------------------- */ 356 | 357 | /* Fixes vega viewer resizing into infinity */ 358 | .vega-embed .chart-wrapper { @apply h-auto !important; } 359 | /* fixes fraction separators being overridden by tw’s border-color */ 360 | .katex * { @apply border-black; } 361 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Clojure/Script mode for CodeMirror 6 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 33 | 34 | 35 | 36 | 37 | 38 | 77 | 78 | 79 |
80 |
81 |
82 |

Clojure/Script mode for CodeMirror 6

83 |

84 | Enable a decent Clojure/Script editor experience in the browser.
85 | Built for and by Nextjournal. 86 |

87 | 98 |
99 |
100 |
101 |
102 |

103 | 🤹‍♀️ Try it for yourself 104 |

105 |
106 |
107 |

Try evaluating any of these forms with Alt + !

108 |

109 | In-browser eval is powered by Sci. 110 |

111 |
112 |
113 |
114 |
    115 |
  • 116 | ⚡️ 117 |
    118 | Lightning-fast with lezer incremental parsing
    119 | 120 | Copy clojure/core.clj into 👈 to try! 121 | 122 |
    123 |
  • 124 |
  • 125 | 🥤 126 |
    127 | Slurping & 🤮 Barfing 128 | 129 | 130 | 131 | 132 | 135 | 138 | 139 | 140 | 141 | 144 | 145 | 146 | 147 |
    forward 133 | Ctrl + / 134 | 136 | or Mod + + j / k 137 |
    backward 142 | Ctrl + Alt + / 143 |
    148 |
    149 |
  • 150 |
  • 151 | 💗 152 |
    153 | Semantic Selections 154 | 155 | 156 | 157 | 158 | 161 | 164 | 165 | 166 |
    Expand / Contract 159 | Alt + / 160 | 162 | or Mod + 1 / 2 163 |
    167 |
    168 |
  • 169 |
  • 170 | 🧙 171 |
    172 | Evaluation 173 | 174 | 175 | 176 | 179 | 182 | 183 | 184 | 187 | 190 | 191 | 192 | 195 | 198 | 199 | 200 |
    177 | At Cursor 178 | 180 | Mod + 181 |
    185 | Top-level form 186 | 188 | Mod + + 189 |
    193 | Cell 194 | 196 | Alt + 197 |
    201 |
    202 |
  • 203 |
  • 204 | 🧹 205 |
    206 | Autoformatting 207 |

    208 | following Tonsky’s Better Clojure Formatting 209 |

    210 |
    211 |
  • 212 |
213 |
214 |
215 |
216 |
217 |
218 |

219 | 🎹 Keybindings 220 |

221 |
222 |
223 |
224 |
225 |
226 |

227 | 📦 Use it in your clojure project 228 |

229 |
230 | {:deps {nextjournal/clojure-mode {:git/url "https://github.com/nextjournal/clojure-mode"
231 |                                   :sha "SHA"}}}
232 |         
233 |
234 |
235 |
236 |
237 |

238 | 📦 Use it from NPM 239 |

240 |
241 | import { default_extensions, complete_keymap } from '@nextjournal/clojure-mode'
242 | import { EditorView, drawSelection, keymap } from  '@codemirror/view'
243 | import { EditorState } from  '@codemirror/state'
244 | 
245 | let extensions = [keymap.of(complete_keymap),
246 |                   ...default_extensions]
247 | 
248 | let state = EditorState.create({doc: "... some clojure code...",
249 |                                 extensions: extensions })
250 | 
251 | let editorElt = document.querySelector('#editor')
252 | let editor = new EditorView({state: state,
253 |                              parent: editorElt,
254 |                              extensions: extensions})
255 | 
256 |         
257 |
258 |
259 | 260 |
261 |
262 |

263 | 🛏 Embed it in Markdown 264 |

265 |
266 |
267 |
268 |
269 |
270 |
271 |
© 2020 Nextjournal GmbH
272 |
273 | Nextjournal 274 | Twitter 275 | About us 276 |
277 |
278 |
279 |
280 | 281 | 282 | 283 | -------------------------------------------------------------------------------- /test/nextjournal/clojure_mode_tests.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode-tests 2 | (:require #?@(:squint [] 3 | :cljs [[cljs.test :refer [are testing deftest is]]]) 4 | [nextjournal.clojure-mode :as cm-clojure] 5 | [nextjournal.clojure-mode.util :as util] 6 | [nextjournal.clojure-mode.test-utils :as test-utils] 7 | [nextjournal.clojure-mode.extensions.close-brackets :as close-brackets] 8 | [nextjournal.clojure-mode.commands :as commands] 9 | [nextjournal.clojure-mode.extensions.formatting :as format] 10 | [nextjournal.clojure-mode.extensions.eval-region :as eval-region] 11 | [nextjournal.scratch] 12 | #?@(:squint [] 13 | :cljs [[nextjournal.livedoc :as livedoc]]) 14 | #?(:squint ["assert" :as assert])) 15 | #?(:squint (:require-macros [nextjournal.clojure-mode-tests.macros :refer [deftest are testing is]]))) 16 | 17 | (def extensions 18 | (.concat cm-clojure/default-extensions (eval-region/extension #js {})) 19 | ;; optionally test with live grammar 20 | #_ 21 | #js[(cm-clojure/syntax live-grammar/parser) 22 | (.slice cm-clojure/default-extensions 1)]) 23 | 24 | (def apply-f (partial test-utils/apply-f extensions)) 25 | (def apply-cmd (partial test-utils/apply-cmd extensions)) 26 | 27 | #?(:squint nil 28 | :cljs (def apply-embedded-f (partial test-utils/apply-f #js [livedoc/markdown-language-support]))) 29 | 30 | #?(:squint nil 31 | :cljs (def apply-embedded-cmd (partial test-utils/apply-cmd #js [livedoc/markdown-language-support]))) 32 | 33 | (do 34 | (deftest nav 35 | (are [input dir expected] 36 | (= (apply-f (commands/nav dir) input) 37 | expected) 38 | "|()" 1 "()|" 39 | "()|" -1 "|()" 40 | "a|b" 1 "ab|" 41 | "a|b" -1 "|ab" 42 | "| ab" 1 " ab|" 43 | "ab |" -1 "|ab " 44 | "(|)" 1 "()|" 45 | "(|)" -1 "|()" 46 | "a|\nb" 1 "a\nb|")) 47 | 48 | 49 | (deftest nav-select 50 | (are [input dir expected] 51 | (= (apply-f (commands/nav-select dir) input) 52 | expected) 53 | "|()" 1 "<()>" 54 | "()|" -1 "<()>" 55 | "a|b" 1 "a" 56 | "(|)" 1 "<()>" 57 | "\"a|b\"" 1 "\"a\"" 58 | "\"a\"" 1 "<\"ab\">" 59 | "a|b" -1 "b" 60 | "| ab" 1 "< ab>" 61 | "ab |" -1 "" 62 | "(|)" 1 "<()>" 63 | "(|)" -1 "<()>" 64 | "a|\nb" 1 "a<\nb>" 65 | )) 66 | 67 | 68 | (deftest close-brackets 69 | (testing "handle-open" 70 | (are [input insert expected] 71 | (= (apply-f #(close-brackets/handle-open % insert) input) 72 | expected) 73 | "|" \( "(|)" ;; auto-close brackets 74 | "(|" \( "((|)" 75 | "|(" \( "(|)(" 76 | "|)" \( "(|))" 77 | "#|" \( "#(|)" 78 | "\"|\"" \( "\"(|\"" ;; no auto-close inside strings 79 | )) 80 | 81 | (testing "handle-close" 82 | (are [input bracket expected] 83 | (= (apply-f #(close-brackets/handle-close % bracket) input) 84 | expected) 85 | "|" \) "|" 86 | "|(" \) "|(" 87 | "|)" \) ")|" 88 | "(|)" \) "()|" 89 | "() |()" \) "() ()|" 90 | "[(|)]" \) "[()|]" 91 | "[()|]" \) "[()]|" 92 | "([]| s)" \) "([] s)|" 93 | "(|" \) "()|" ;; close unclosed parent 94 | "[(|]" \} "[(]|" ;; non-matching bracket doesn't close ancestor 95 | "((|)" \] "(()|" ;; non-matching bracket doesn't close ancestor 96 | "((|)" \) "(())|" ;; a bit weird - it finds an unclosed ancestor, and closes that. 97 | "\"|\"" \) "\")|\"" ;; normal behaviour inside strings 98 | )) 99 | 100 | 101 | 102 | (testing "handle-open string" 103 | (are [input expected] 104 | (= (apply-f #(close-brackets/handle-open % \") input) expected) 105 | "|" "\"|\"" ;; auto-close strings 106 | "\"|\"" "\"\\\"|\"" ;; insert quoted " inside strings 107 | )) 108 | 109 | (testing "handle-backspace" 110 | (are [input expected] 111 | (= (apply-f close-brackets/handle-backspace input) 112 | expected) 113 | "|" "|" 114 | "(|" "|" ;; delete an unbalanced paren 115 | "()|" "(|)" ;; enter a form from the right (do not "unbalance") 116 | "#|()" "|()" ;; delete prefix form 117 | "[[]]|" "[[]|]" 118 | "(| )" "|" ;; delete empty form 119 | "(| a)" "(| a)" ;; don't delete non-empty forms 120 | "@|" "|" ;; delete @ 121 | "@|x" "|x" 122 | "\"|\"" "|" ;; delete empty string 123 | "\"\"|" "\"|\"" 124 | "\"| \"" "\"| \"" ;; do not delete string with whitespace 125 | ":x :a |" ":x :a|" ;; do not format on backspace 126 | "\"[|]\"" "\"|]\"" ;; normal deletion inside strings 127 | "( ;;\n|)" "( ;;|\n)" ;; don't put paren behind line comment 128 | "( ;|\n)" "( |\n)" 129 | "(;;\n|;;\n)" "(;;|;;\n)")) 130 | 131 | #?(:squint nil 132 | :cljs (testing "handle backspace (embedded)" 133 | (are [input expected] 134 | (= (apply-embedded-f close-brackets/handle-backspace input) 135 | expected) 136 | "```\n()|\n```" "```\n(|)\n```" 137 | "```\n[[]]|\n```" "```\n[[]|]\n```" 138 | "```\n(| )\n```" "```\n|\n```" 139 | )))) 140 | 141 | (deftest indentSelection 142 | 143 | (are [input expected] 144 | (= (apply-f format/format (str "<" input ">")) 145 | (str "<" expected ">")) 146 | " ()" "()" ;; top-level => 0 indent 147 | "(\n)" "(\n )" 148 | "(b\n)" "(b\n )" ;; operator gets extra indent (symbol in 1st position) 149 | "(0\n)" "(0\n )" ;; a number is not operator 150 | "(:a\n)" "(:a\n )" ;; a keyword is not operator 151 | "(a\n\nb)" "(a\n \n b)" ;; empty lines get indent 152 | ) 153 | 154 | (testing "prefix-all" 155 | (are [before after] 156 | (= (apply-f (partial format/prefix-all "a") before) 157 | after) 158 | "z|z\nzz|\n|zz" "az|z\nazz|\n|azz" 159 | "z\nz" "az\naz" 160 | 161 | ))) 162 | 163 | (deftest indent-all ;; same as indentSelection but applies to entire doc 164 | (are [input expected] 165 | (= (apply-f format/indent-all input) 166 | expected) 167 | "| ()" "|()" 168 | "|()[\n]" "|()[\n ]" 169 | "|(\n)" "|(\n )" 170 | "(\n)" "(\n )" 171 | "|(0\nx<)>" "|(0\n x<)>" 172 | "<(:a\n)>" "<(:a\n )>" 173 | "|(a\n\nb)" "|(a\n\n b)" 174 | 175 | )) 176 | 177 | (deftest format-all 178 | (are [input expected] 179 | (= (apply-f format/format-all input) 180 | expected) 181 | "a :b 3 |" "a :b 3|" ;; remove extra spaces 182 | "\"\" |:a " "\"\" |:a" 183 | "(|a )" "(|a)" 184 | "| ( )" "|()" 185 | "|()a" "|() a" ;; add needed spaces 186 | "() |a" "() |a" ;; cursor position 187 | "()| a" "()| a" 188 | "() | a" "() |a" 189 | "|(\n )" "|(\n )" 190 | "(\n)" "(\n )" 191 | "<(:a\n)>" "<(:a\n )>" 192 | "|(a\n\nb)" "|(a\n\n b)" 193 | "|\"a\"" "|\"a\"" 194 | "#_a|" "#_a|" 195 | "[ | ]" "[|]" 196 | "|[] " "|[]" 197 | "#(|a )" "#(|a)" 198 | 199 | "|@ a" "|@a" 200 | "|&" "|&" 201 | "[_ & |_]" "[_ & |_]" 202 | 203 | "|[a [\n]]" "|[a [\n ]]" 204 | "|[a [\n]]" "|[a [\n ]]" 205 | 206 | "|[ a \n]" "|[a\n ]" 207 | "|[ a [\n]]" "|[a [\n ]]" 208 | "|[ \n[ \n[ ]]]" "|[\n [\n []]]" 209 | "|()[\n]" "|() [\n ]" ;; closing-bracket 1 space in front of opening-bracket 210 | "|()[\n]" "|() [\n ]" 211 | )) 212 | 213 | (deftest format-selection 214 | (are [input expected] 215 | (= (apply-f format/format input) 216 | expected) 217 | "\nc d" "\nc d" ;; only selected lines are formatted 218 | " c \na b" " c \na b" ;; multiple selectons on one line 219 | )) 220 | 221 | (deftest kill 222 | (are [input expected] 223 | (= (apply-cmd commands/kill input) 224 | expected) 225 | "| ()\nx" "|\nx" ;; top-level 226 | " \"ab|c\" " "\"ab|\"" ;; kill to end of string 227 | " \"|a\nb\"" "\"|b\"" ;; TODO - stop at newline within string 228 | "(|)" "(|)" ;; no-op in empty coll 229 | "(| x y [])" "(|)" ;; kill all coll contents 230 | "a| \nb" "a|b" ;; bring next line up 231 | 232 | )) 233 | 234 | (deftest unwrap 235 | (are [input expected] 236 | (= (apply-cmd commands/unwrap input) 237 | expected) 238 | "(|)" "|" 239 | "[a | b]" "a |b" 240 | "a|b" "a|b")) 241 | 242 | (deftest balance-ranges 243 | (are [input expected] 244 | (= (apply-f commands/balance-ranges input) 245 | expected) 246 | "" "" 247 | "a" "a" 248 | " \"a<\"> " " <\"a\"> " 249 | "(<)>" "<()>" 250 | "(" "<(a) b>" 251 | )) 252 | 253 | (deftest slurp-test 254 | (are [input dir expected] 255 | (= (apply-f (commands/slurp dir) input) expected) 256 | "(|) a" 1 "(|a)" 257 | "((|)) a" 1 "((|) a)" 258 | "(|) ;;comment\na" 1 "(|;;comment\n a)" ;; slurp around comments 259 | "a(|)" -1 "(a|)" 260 | "a ;; hello\n(|)" -1 "(a ;; hello\n | )" 261 | "a #:b{|}" -1 "#:b{a|}" 262 | 263 | "a #(|)" -1 "#(a|)" 264 | "#(|) a" 1 "#(|a)" 265 | "@(|) a" 1 "@(|a)" 266 | "#::a{|:a} 1" 1 "#::a{|:a 1}" 267 | "'(|) 1" 1 "'(|1)" 268 | 269 | "^{|} :x :a " 1 "^{|:x} :a" 270 | "^{|} :x 1" 1 "^{|:x} 1" 271 | "^{} [|] :x" 1 "^{} [|:x]" 272 | 273 | "('is-d|ata) :x" 1 "('is-d|ata :x)" 274 | "('xy|z 1) 2" 1 "('xy|z 1 2)" 275 | "'ab|c 1" 1 "'ab|c 1" 276 | 277 | "\"x|\" 1" 1 "\"x| 1\"" 278 | "1 \"x|\"" -1 "\"1 x|\"")) 279 | 280 | #?(:squint nil 281 | :cljs 282 | (deftest slurp-embedded 283 | (are [input dir expected] 284 | (= (apply-embedded-f (commands/slurp dir) input) expected) 285 | "```\n(|) a\n```" 1 "```\n(|a)\n```" 286 | "```\n((|)) a\n```" 1 "```\n((|) a)\n```" 287 | "```\n(|) ;;comment\na\n```" 1 "```\n(|;;comment\n a)\n```" 288 | "```\n('xy|z 1) 2\n```" 1 "```\n('xy|z 1 2)\n```" 289 | ))) 290 | 291 | (deftest barf 292 | (are [input dir expected] 293 | (= (apply-f (commands/barf dir) input) expected) 294 | "(|a)" 1 "(|) a" 295 | "(|a)" -1 "a (|)" 296 | "((|)a)" 1 "((|)a)" 297 | 298 | "#(|a)" -1 "a #(|)" 299 | "#(|a)" 1 "#(|) a" 300 | 301 | "#:b{a|}" -1 "a #:b{|}" 302 | )) 303 | 304 | (deftest grow-selections 305 | (are [input expected] 306 | (= (apply-cmd commands/selection-grow input) expected) 307 | 308 | "(|)" "<()>" 309 | "(|a)" "()" 310 | "(a|)" "()" 311 | "\"|\"" "<\"\">" 312 | "\"a|b\"" "\"\"" 313 | "[|]" "<[]>" 314 | ";; hell|o" "<;; hello>" 315 | 316 | "( a|)" "( )" 317 | "( )" "(< a>)" 318 | "(< a>)" "<( a)>" 319 | 320 | "@" "<@deref>" 321 | )) 322 | 323 | (deftest enter-and-indent 324 | (are [input expected] 325 | (= (apply-cmd commands/enter-and-indent input) expected) 326 | 327 | "(|)" "(\n |)" 328 | "((|))" "((\n |))" 329 | "(()|)" "(()\n |)" 330 | "(a |b)" "(a\n |b)" 331 | "(a b|c)" "(a b\n |c)" 332 | )) 333 | 334 | (deftest eval-region-test 335 | (are [input f expected] 336 | (= (f (test-utils/make-state extensions input)) expected) 337 | "(+ |1 2 3)" eval-region/cursor-node-string "1" 338 | "(+ |(+ 1 2) 2 3)" eval-region/cursor-node-string "(+ 1 2)" 339 | "(+ (+ 1 2)| 2 3)" eval-region/cursor-node-string "(+ 1 2)" 340 | "(+ #_(+ 1 2)| 2 3)" eval-region/cursor-node-string "(+ 1 2)" 341 | "(+ #_#_1 (+ 1 2)| 2 3)" eval-region/cursor-node-string "(+ 1 2)") 342 | (let [state (test-utils/make-state extensions ";; dude\n|{:a 1}")] 343 | (is (= "{:a 1}" (->> (eval-region/top-level-node state) 344 | (util/range-str state))))))) 345 | 346 | #_(prn (eval-region/cursor-node-string (test-utils/make-state extensions "(+ (+ 1 2)| 2 3)"))) 347 | -------------------------------------------------------------------------------- /squint-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Clojure/Script mode for CodeMirror 6 7 | 8 | 9 | 10 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 93 | 94 | 95 |
96 |
97 |
98 |

Clojure/Script mode for CodeMirror 6

99 |

100 | Enable a decent Clojure/Script editor experience in the browser.
101 | Built for and by Nextjournal. 102 |

103 | 114 |
115 |
116 |
117 |
118 |

119 | 🤹‍♀️ Try it for yourself 120 |

121 |
122 |
123 | 124 | 125 | 126 | 127 |
128 |
129 |
130 |
131 |
    132 |
  • 133 | ⚡️ 134 |
    135 | Lightning-fast with lezer incremental parsing
    136 | 137 | Copy clojure/core.clj into 👈 to try! 138 | 139 |
    140 |
  • 141 |
  • 142 | 🥤 143 |
    144 | Slurping & 🤮 Barfing 145 | 146 | 147 | 148 | 149 | 152 | 155 | 156 | 157 | 158 | 161 | 162 | 163 | 164 |
    forward 150 | Ctrl + / 151 | 153 | or Mod + + j / k 154 |
    backward 159 | Ctrl + Alt + / 160 |
    165 |
    166 |
  • 167 |
  • 168 | 💗 169 |
    170 | Semantic Selections 171 | 172 | 173 | 174 | 175 | 178 | 181 | 182 | 183 |
    Expand / Contract 176 | Alt + / 177 | 179 | or Mod + 1 / 2 180 |
    184 |
    185 |
  • 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 |
  • 221 | 🧹 222 |
    223 | Autoformatting 224 |

    225 | following Tonsky’s Better Clojure Formatting 226 |

    227 |
    228 |
  • 229 |
230 |
231 |
232 |
233 |
234 |
235 |

236 | 📦 Use it in your project 237 |

238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
© 2020 Nextjournal GmbH
246 |
247 | Nextjournal 248 | Twitter 249 | About us 250 |
251 |
252 |
253 |
254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /demo/src/nextjournal/clojure_mode/demo.cljs: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.demo 2 | (:require ["@codemirror/commands" :refer [history historyKeymap]] 3 | ["@codemirror/language" :refer [foldGutter syntaxHighlighting defaultHighlightStyle]] 4 | ["@codemirror/lang-javascript" :refer [javascript]] 5 | ["@codemirror/state" :refer [EditorState]] 6 | ["@codemirror/view" :as view :refer [EditorView]] 7 | ["react" :as react] 8 | [applied-science.js-interop :as j] 9 | [clojure.string :as str] 10 | [nextjournal.clerk.sci-viewer :as sv] 11 | [nextjournal.clerk.viewer :as v] 12 | [nextjournal.clojure-mode :as cm-clj] 13 | [nextjournal.clojure-mode.demo.sci :as demo.sci] 14 | [nextjournal.clojure-mode.extensions.eval-region :as eval-region] 15 | [nextjournal.clojure-mode.keymap :as keymap] 16 | [nextjournal.clojure-mode.live-grammar :as live-grammar] 17 | [nextjournal.clojure-mode.test-utils :as test-utils] 18 | [nextjournal.livedoc :as livedoc] 19 | [reagent.core :as r] 20 | [reagent.dom :as rdom] 21 | [shadow.resource :as rc])) 22 | 23 | (def theme 24 | (.theme EditorView 25 | (j/lit {".cm-content" {:white-space "pre-wrap" 26 | :padding "10px 0" 27 | :flex "1 1 0"} 28 | 29 | "&.cm-focused" {:outline "0 !important"} 30 | ".cm-line" {:padding "0 9px" 31 | :line-height "1.6" 32 | :font-size "16px" 33 | :font-family "var(--code-font)"} 34 | ".cm-matchingBracket" {:border-bottom "1px solid var(--teal-color)" 35 | :color "inherit"} 36 | ".cm-gutters" {:background "transparent" 37 | :border "none"} 38 | ".cm-gutterElement" {:margin-left "5px"} 39 | ;; only show cursor when focused 40 | ".cm-cursor" {:visibility "hidden"} 41 | "&.cm-focused .cm-cursor" {:visibility "visible"}}))) 42 | 43 | (defonce extensions #js[theme 44 | (history) 45 | (syntaxHighlighting defaultHighlightStyle) 46 | (view/drawSelection) 47 | (foldGutter) 48 | (.. EditorState -allowMultipleSelections (of true)) 49 | (if false 50 | ;; use live-reloading grammar 51 | #js[(cm-clj/syntax live-grammar/parser) 52 | (.slice cm-clj/default-extensions 1)] 53 | cm-clj/default-extensions) 54 | (.of view/keymap cm-clj/complete-keymap) 55 | (.of view/keymap historyKeymap)]) 56 | 57 | 58 | (defn editor [source {:keys [eval?]}] 59 | (r/with-let [!view (r/atom nil) 60 | last-result (when eval? (r/atom (demo.sci/eval-string source))) 61 | mount! (fn [el] 62 | (when el 63 | (reset! !view (new EditorView 64 | (j/obj :state 65 | (test-utils/make-state 66 | (cond-> #js [extensions] 67 | eval? (.concat #js [(eval-region/extension {:modifier "Meta"}) 68 | (demo.sci/extension {:modifier "Meta" 69 | :on-result (partial reset! last-result)})])) 70 | source) 71 | :parent el)))))] 72 | [:div 73 | [:div {:class "rounded-md mb-0 text-sm monospace overflow-auto relative border shadow-lg bg-white" 74 | :ref mount! 75 | :style {:max-height 410}}] 76 | (when eval? 77 | [:div.mt-3.mv-4.pl-6 {:style {:white-space "pre-wrap" :font-family "var(--code-font)"}} 78 | (when-some [{:keys [error result]} @last-result] 79 | (cond 80 | error [:div.red error] 81 | (react/isValidElement result) result 82 | 'else (sv/inspect-paginated result)))])] 83 | (finally 84 | (j/call @!view :destroy)))) 85 | 86 | ;; Markdown editors 87 | (defn markdown-editor [{:keys [doc extensions]}] 88 | [:div {:ref (fn [^js el] 89 | (when el 90 | (some-> el .-editorView .destroy) 91 | (j/assoc! el :editorView 92 | (EditorView. (j/obj :parent el 93 | :state (.create EditorState 94 | (j/obj :doc (str/trim doc) 95 | :extensions (into-array 96 | (cond-> [(syntaxHighlighting defaultHighlightStyle) 97 | (foldGutter) 98 | (.of view/keymap cm-clj/complete-keymap) 99 | (history) 100 | (.of view/keymap historyKeymap) 101 | theme 102 | livedoc/markdown-language-support] 103 | (seq extensions) 104 | (concat extensions))))))))))}]) 105 | 106 | (defn samples [] 107 | (into [:<>] 108 | (for [source ["(comment 109 | (fizz-buzz 1) 110 | (fizz-buzz 3) 111 | (fizz-buzz 5) 112 | (fizz-buzz 15) 113 | (fizz-buzz 17) 114 | (fizz-buzz 42)) 115 | 116 | (defn fizz-buzz [n] 117 | (condp (fn [a b] (zero? (mod b a))) n 118 | 15 \"fizzbuzz\" 119 | 3 \"fizz\" 120 | 5 \"buzz\" 121 | n))"]] 122 | [editor source {:eval? true}]))) 123 | 124 | (defn linux? [] 125 | (some? (re-find #"(Linux)|(X11)" js/navigator.userAgent))) 126 | 127 | (defn mac? [] 128 | (and (not (linux?)) 129 | (some? (re-find #"(Mac)|(iPhone)|(iPad)|(iPod)" js/navigator.platform)))) 130 | 131 | (defn key-mapping [] 132 | (cond-> {"ArrowUp" "↑" 133 | "ArrowDown" "↓" 134 | "ArrowRight" "→" 135 | "ArrowLeft" "←" 136 | "Mod" "Ctrl"} 137 | (mac?) 138 | (merge {"Alt" "⌥" 139 | "Shift" "⇧" 140 | "Enter" "⏎" 141 | "Ctrl" "⌃" 142 | "Mod" "⌘"}))) 143 | 144 | (defn render-key [key] 145 | (let [keys (into [] (map #(get ((memoize key-mapping)) % %) (str/split key #"-")))] 146 | (into [:span] 147 | (map-indexed (fn [i k] 148 | [:<> 149 | (when-not (zero? i) [:span " + "]) 150 | [:kbd.kbd k]]) keys)))) 151 | 152 | (defn key-bindings-table [keymap] 153 | [:table.w-full.text-sm 154 | [:thead 155 | [:tr.border-t 156 | [:th.px-3.py-1.align-top.text-left.text-xs.uppercase.font-normal.black-50 "Command"] 157 | [:th.px-3.py-1.align-top.text-left.text-xs.uppercase.font-normal.black-50 "Keybinding"] 158 | [:th.px-3.py-1.align-top.text-left.text-xs.uppercase.font-normal.black-50 "Alternate Binding"] 159 | [:th.px-3.py-1.align-top.text-left.text-xs.uppercase.font-normal.black-50 {:style {:min-width 290}} "Description"]]] 160 | (into [:tbody] 161 | (->> keymap 162 | (sort-by first) 163 | (map (fn [[command [{:keys [key shift doc]} & [{alternate-key :key}]]]] 164 | [:<> 165 | [:tr.border-t.hover:bg-gray-100 166 | [:td.px-3.py-1.align-top.monospace.whitespace-nowrap [:b (name command)]] 167 | [:td.px-3.py-1.align-top.text-right.text-sm.whitespace-nowrap (render-key key)] 168 | [:td.px-3.py-1.align-top.text-right.text-sm.whitespace-nowrap (some-> alternate-key render-key)] 169 | [:td.px-3.py-1.align-top doc]] 170 | (when shift 171 | [:tr.border-t.hover:bg-gray-100 172 | [:td.px-3.py-1.align-top [:b (name shift)]] 173 | [:td.px-3.py-1.align-top.text-sm.whitespace-nowrap.text-right 174 | (render-key (str "Shift-" key))] 175 | [:td.px-3.py-1.align-top.text-sm] 176 | [:td.px-3.py-1.align-top]])]))))]) 177 | 178 | (defn js-syntax [source] 179 | (r/with-let [!view (r/atom nil) 180 | mount! (fn [el] 181 | (when el 182 | (reset! !view (new EditorView 183 | (j/obj :parent el 184 | :state (.create EditorState (j/obj :doc source 185 | :extensions (to-array [(javascript (j/obj)) 186 | (syntaxHighlighting defaultHighlightStyle) 187 | (.. EditorState -readOnly (of true)) 188 | (foldGutter) 189 | theme]))))))))] 190 | [:div 191 | [:div {:class "rounded-md mb-0 text-sm monospace overflow-auto relative border shadow-lg bg-white" :ref mount! :style {:max-height 410}}]] 192 | (finally 193 | (j/call @!view :destroy)))) 194 | 195 | (defn ^:dev/after-load render [] 196 | (rdom/render [samples] (js/document.getElementById "editor")) 197 | (.. (js/document.querySelectorAll "[clojure-mode]") 198 | (forEach #(when-not (.-firstElementChild %) 199 | (rdom/render [editor (str/trim (.-innerHTML %))] %)))) 200 | 201 | (.. (js/document.querySelectorAll "[js-mode]") 202 | (forEach #(when-not (.-firstElementChild %) 203 | (rdom/render [js-syntax (str/trim (.-innerHTML %))] %)))) 204 | 205 | (let [mapping (key-mapping)] 206 | (.. (js/document.querySelectorAll ".mod,.alt,.ctrl") 207 | (forEach #(when-let [k (get mapping (.-innerHTML %))] 208 | (set! (.-innerHTML %) k))))) 209 | 210 | ;; set viewer tailwind stylesheet 211 | (j/assoc! (js/document.getElementById "viewer-stylesheet") 212 | :innerHTML (rc/inline "stylesheets/viewer.css")) 213 | 214 | (rdom/render [key-bindings-table (merge keymap/paredit-keymap* (demo.sci/keymap* "Mod"))] (js/document.getElementById "docs")) 215 | (rdom/render [:div.rounded-md.mb-0.text-sm.monospace.overflow-auto.relative.border.shadow-lg.bg-white 216 | [markdown-editor {:doc "# Hello Markdown 217 | 218 | Lezer [mounted trees](https://lezer.codemirror.net/docs/ref/#common.MountedTree) allows to 219 | have an editor with ~~mono~~ _mixed language support_. 220 | 221 | ```clojure 222 | (defn the-answer 223 | \"to all questions\" 224 | [] 225 | (inc 41)) 226 | ``` 227 | 228 | ## Todo 229 | - [x] resolve **inner nodes** 230 | - [x] fix extra spacing when autoformatting after paredit movements 231 | - [x] fix errors when entering a newline 232 | - [ ] fix extra space when entering a newline 233 | - [x] fix nonsense deletions hitting delete key 234 | - [x] limit the scope of autoformat (TAB) 235 | - [x] limit the scope of kill* 236 | - [x] limit the scope of eval-region 237 | - [ ] restore autoformat when deleting 238 | - [x] keep parens balanced when deleting backward 239 | - [x] fix errors on Ctrl-K 240 | - [ ] fix dark theme 241 | - [ ] fix demo error: CssSyntaxError: :62:15: The `font-inter` class does not exist 242 | "}]] (js/document.getElementById "markdown-editor")) 243 | 244 | (when (linux?) 245 | (js/twemoji.parse (.-body js/document)))) 246 | 247 | (comment 248 | (let [ctx' (sci.core/fork @sv/!sci-ctx) 249 | ctx'' (sci.core/merge-opts ctx' {:namespaces {'foo {'bar "ahoi"}}})] 250 | 251 | (demo.sci/eval-string ctx'' "(def o (j/assoc! #js {:a 1} :b 2))") 252 | (demo.sci/eval-string ctx'' "(j/lookup (j/assoc! #js {:a 1} :b 2))") 253 | (demo.sci/eval-string ctx'' "(j/get o :b)") 254 | (demo.sci/eval-string ctx'' "(into-array [1 2 3])") 255 | 256 | ;; this is not evaluable as-is in sci 257 | (demo.sci/eval-string ctx'' "(j/let [^:js {:keys [a b]} o] (map inc [a b]))"))) 258 | -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/commands.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.commands 2 | (:require ["@codemirror/commands" :as commands] 3 | #?@(:squint [] 4 | :cljs [[applied-science.js-interop :as j]]) 5 | [nextjournal.clojure-mode.util :as u] 6 | [nextjournal.clojure-mode.selections :as sel] 7 | [nextjournal.clojure-mode.node :as n] 8 | [nextjournal.clojure-mode.extensions.formatting :as format] 9 | [nextjournal.clojure-mode.extensions.selection-history :as sel-history]) 10 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 11 | 12 | (defn view-command [f] 13 | (j/fn [^:js {:keys [^js state dispatch]}] 14 | (some-> (f state) (dispatch)) 15 | true)) 16 | 17 | ;; some commands won't make sense when clojure is embedded into other languages 18 | ;; in which case we want default commands/envent-handling applied 19 | (defn scoped-view-command [f] 20 | (j/fn [^:js {:keys [^js state dispatch]}] 21 | (if (n/within-program? state) 22 | (do (some-> (f state) (dispatch)) 23 | true) 24 | false))) 25 | 26 | (defn unwrap* [state] 27 | (u/update-ranges state 28 | (j/fn [^:js {:as range :keys [from to empty]}] 29 | (when empty 30 | (when-let [nearest-balanced-coll 31 | (some-> (n/tree state from -1) 32 | (n/closest n/coll?) 33 | (u/guard n/balanced?))] 34 | {:cursor (dec from) 35 | :changes [(n/from-to (n/down nearest-balanced-coll)) 36 | (n/from-to (n/down-last nearest-balanced-coll))]}))))) 37 | 38 | (defn copy-to-clipboard! [text] 39 | (let [^js focus-el (j/get js/document :activeElement) 40 | input-el (js/document.createElement "textarea")] 41 | (.setAttribute input-el "class" "clipboard-input") 42 | (j/assoc! input-el :innerHTML text) 43 | (-> js/document .-body (.appendChild input-el)) 44 | (.focus input-el #js {:preventScroll true}) 45 | (.select input-el) 46 | (js/document.execCommand "copy") 47 | (.focus focus-el #js {:preventScroll true}) 48 | (-> js/document .-body (.removeChild input-el)))) 49 | 50 | (defn kill* [^js state] 51 | (u/update-ranges state 52 | (j/fn [^:js {:as range :keys [from to empty]}] 53 | (if empty 54 | (let [node (n/tree state from) 55 | parent (n/closest node #(or (n/coll? %) 56 | (n/string? %) 57 | (n/top? %))) 58 | line-end (.-to (.lineAt (.-doc state) from)) 59 | next-children (when parent (n/children parent from 1)) 60 | last-child-on-line 61 | (when parent (some->> next-children 62 | (take-while (every-pred 63 | #(<= (n/start %) line-end))) 64 | last)) 65 | to (cond (n/string? parent) (let [content (str (n/string state parent)) 66 | content-from (subs content (- from (n/start parent))) 67 | next-newline (.indexOf content-from \newline)] 68 | (if (neg? next-newline) 69 | (dec (n/end parent)) 70 | (+ from next-newline 1))) 71 | last-child-on-line (if (n/end-edge? last-child-on-line) 72 | (n/start last-child-on-line) 73 | (n/end last-child-on-line)) 74 | (some-> (first next-children) 75 | n/start 76 | (> line-end)) (-> (first next-children) n/start))] 77 | (when-not u/node-js? 78 | (copy-to-clipboard! (n/string state from to))) 79 | (when to 80 | {:cursor from 81 | :changes {:from from 82 | :to to}})) 83 | (do 84 | (copy-to-clipboard! (n/string state from to)) 85 | {:cursor from 86 | :changes (u/from-to from to)}))))) 87 | 88 | (defn enter-and-indent* [^js state] 89 | (let [ctx (format/make-indent-context state)] 90 | (u/update-ranges state 91 | (j/fn [^:js {:as range :keys [from to empty]}] 92 | (let [indent-at (some-> (n/closest (n/tree state from) (some-fn n/coll? n/top?)) 93 | n/inner-span 94 | n/start) 95 | indent (when indent-at (format/get-indentation ctx indent-at)) 96 | insertion (str \newline (when indent (format/spaces state indent)))] 97 | {:cursor (+ from (count insertion)) 98 | :changes [{:from from 99 | :to to 100 | :insert insertion}]}))))) 101 | 102 | (defn nav-position [state from dir] 103 | (or (some-> (n/closest (n/tree state from) 104 | #(or (n/coll? %) 105 | (n/string? %) 106 | (n/top? %))) 107 | (n/children from dir) 108 | first 109 | (j/get (case dir -1 :from 1 :to))) 110 | (sel/constrain state (+ from dir)))) 111 | 112 | (defn nav [dir] 113 | (fn [state] 114 | (u/update-ranges state 115 | (j/fn [^:js {:keys [from to empty]}] 116 | (if empty 117 | {:cursor (nav-position state from dir)} 118 | {:cursor (j/get (u/from-to from to) (case dir -1 :from 1 :to))}))))) 119 | 120 | (defn nav-select [dir] 121 | (fn [^js state] 122 | (u/update-ranges state 123 | (j/fn [^:js {:keys [from to empty]}] 124 | (if empty 125 | {:range (n/balanced-range state from (nav-position state from dir))} 126 | {:range (j/let [^:js {:keys [from to]} (u/from-to from to)] 127 | (case dir 128 | 1 (n/balanced-range state from (nav-position state to dir)) 129 | -1 (n/balanced-range state (nav-position state from dir) to)))}))))) 130 | 131 | (defn balance-ranges [^js state] 132 | (u/update-ranges state 133 | (j/fn [^:js {:keys [from to empty]}] 134 | (when-not empty 135 | {:range (n/balanced-range state from to)})))) 136 | 137 | (defn slurp [direction] 138 | (fn [^js state] 139 | (u/update-ranges state 140 | (j/fn [^:js {:keys [from empty]}] 141 | (when empty 142 | (when-let [parent (n/closest (n/tree state from) 143 | (every-pred (some-fn n/coll? 144 | n/string?) 145 | #(not 146 | (case direction 147 | 1 (some-> % n/with-prefix n/right n/end-edge?) 148 | -1 (some-> % n/with-prefix n/left n/start-edge?)))))] 149 | (let [str? (n/string? parent)] 150 | (when-let [target (case direction 1 (first (remove n/line-comment? (n/rights (n/with-prefix parent)))) 151 | -1 (first (remove n/line-comment? (n/lefts (n/with-prefix parent)))))] 152 | {:cursor/mapped from 153 | :changes (case direction 154 | 1 155 | (let [edge (n/down-last parent)] 156 | #js [#js {:from (-> target n/end) 157 | :insert (n/name edge)} 158 | (-> edge 159 | n/from-to 160 | (cond-> 161 | (not str?) (j/assoc! :insert " ")))]) 162 | -1 163 | (let [^string edge (n/left-edge-with-prefix state parent) 164 | start (n/start (n/with-prefix parent))] 165 | #js [(cond-> #js {:from start 166 | :to (+ start (count edge))} 167 | (not str?) (j/assoc! :insert " ")) 168 | #js {:from (n/start target) 169 | :insert edge}]))})))))))) 170 | 171 | (defn barf [direction] 172 | (fn [^js state] 173 | (->> (j/fn [^:js {:keys [from empty]}] 174 | (when empty 175 | (when-let [parent (-> (n/tree state from) 176 | (n/closest n/coll?))] 177 | (case direction 178 | 1 179 | (when-let [target (some->> (n/down-last parent) 180 | n/lefts 181 | (remove n/line-comment?) 182 | (drop 1) 183 | first)] 184 | 185 | {:cursor (min (n/end target) from) 186 | :changes [{:from (n/end target) 187 | :insert (n/name (n/down-last parent))} 188 | (-> (n/down-last parent) 189 | n/from-to 190 | (j/assoc! :insert " "))]}) 191 | -1 192 | (when-let [next-first-child (some->> (n/down parent) 193 | n/rights 194 | (remove n/line-comment?) 195 | (drop 1) 196 | first)] 197 | (let [left-edge (n/left-edge-with-prefix state parent) 198 | left-start (n/start (n/with-prefix parent))] 199 | {:cursor (max from (+ (n/start next-first-child) (inc (count left-edge)))) 200 | :changes [ 201 | ;; insert left edge (prefixed by a space) in front of next-first-child 202 | {:from (n/start next-first-child) 203 | :insert (str " " left-edge)} 204 | ;; replace left-edge with spaces 205 | {:from left-start 206 | :to (+ left-start (count left-edge)) 207 | :insert (format/spaces state (count left-edge))}]})))))) 208 | (u/update-ranges state)))) 209 | 210 | (def builtin-index 211 | "Subset of builtin commands that compliment paredit" 212 | {:cursorLineStart commands/cursorLineStart 213 | :selectLineStart commands/selectLineStart 214 | :cursorLineDown commands/cursorLineDown 215 | :selectLineDown commands/selectLineDown 216 | :selectAll commands/selectAll 217 | :cursorLineBoundaryForward commands/cursorLineBoundaryForward 218 | :selectLineBoundaryForward commands/selectLineBoundaryForward 219 | :deleteCharBackward commands/deleteCharBackward 220 | :insertNewlineAndIndent commands/insertNewlineAndIndent 221 | :cursorLineBoundaryBackward commands/cursorLineBoundaryBackward 222 | :selectLineBoundaryBackward commands/selectLineBoundaryBackward 223 | :deleteCharForward commands/deleteCharForward 224 | :cursorCharLeft commands/cursorCharLeft 225 | :selectCharLeft commands/selectCharLeft 226 | :cursorCharRight commands/cursorCharRight 227 | :selectCharRight commands/selectCharRight 228 | :cursorGroupForward commands/cursorGroupForward 229 | :selectGroupForward commands/selectGroupForward 230 | :cursorGroupBackward commands/cursorGroupBackward 231 | :selectGroupBackward commands/selectGroupBackward 232 | :cursorDocEnd commands/cursorDocEnd 233 | :selectDocEnd commands/selectDocEnd 234 | :deleteGroupBackward commands/deleteGroupBackward 235 | :deleteGroupForward commands/deleteGroupForward 236 | :cursorPageDown commands/cursorPageDown 237 | :selectPageDown commands/selectPageDown 238 | :cursorPageUp commands/cursorPageUp 239 | :selectPageUp commands/selectPageUp 240 | :cursorLineEnd commands/cursorLineEnd 241 | :selectLineEnd commands/selectLineEnd 242 | :splitLine commands/splitLine 243 | :transposeChars commands/transposeChars 244 | :cursorLineUp commands/cursorLineUp 245 | :selectLineUp commands/selectLineUp 246 | :cursorDocStart commands/cursorDocStart 247 | :selectDocStart commands/selectDocStart}) 248 | 249 | (def indent (view-command format/format)) 250 | (def unwrap (view-command unwrap*)) 251 | (def kill (scoped-view-command kill*)) 252 | (def nav-right (view-command (nav 1))) 253 | (def nav-left (view-command (nav -1))) 254 | (def nav-select-right (view-command (nav-select 1))) 255 | (def nav-select-left (view-command (nav-select -1))) 256 | (def slurp-forward (view-command (slurp 1))) 257 | (def slurp-backward (view-command (slurp -1))) 258 | (def barf-forward (view-command (barf 1))) 259 | (def barf-backward (view-command (barf -1))) 260 | (def selection-grow (view-command sel-history/selection-grow*)) 261 | (def selection-return (view-command sel-history/selection-return*)) 262 | (def enter-and-indent (view-command enter-and-indent*)) 263 | 264 | (def paredit-index 265 | {:indent indent 266 | :unwrap unwrap 267 | :kill kill 268 | :nav-right nav-right 269 | :nav-left nav-left 270 | :nav-select-right nav-select-right 271 | :nav-select-left nav-select-left 272 | :slurp-forward slurp-forward 273 | :slurp-backward slurp-backward 274 | :barf-forward barf-forward 275 | :barf-backward barf-backward 276 | :selection-grow selection-grow 277 | :selection-return selection-return 278 | :enter-and-indent enter-and-indent}) 279 | 280 | (def index 281 | "Mapping of keyword-id to command functions" 282 | (merge builtin-index 283 | paredit-index)) 284 | 285 | (def reverse-index 286 | "Lookup keyword-id by function" 287 | (reduce-kv #(assoc %1 %3 %2) {} index)) 288 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. -------------------------------------------------------------------------------- /src-shared/nextjournal/clojure_mode/node.cljc: -------------------------------------------------------------------------------- 1 | (ns nextjournal.clojure-mode.node 2 | (:refer-clojure :exclude [coll? ancestors string? empty? regexp? name range resolve type]) 3 | (:require ["@lezer/common" :as lz-tree] 4 | ["@lezer/markdown" :as lezer-markdown] 5 | ["@codemirror/language" :as language] 6 | ["@nextjournal/lezer-clojure" :as lezer-clj] 7 | #?@(:squint [] :cljs [[applied-science.js-interop :as j]]) 8 | [nextjournal.clojure-mode.util :as u] 9 | [nextjournal.clojure-mode.selections :as sel]) 10 | #?(:squint (:require-macros [applied-science.js-interop :as j]))) 11 | 12 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 13 | ;; Node props are marked in the grammar and distinguish categories of nodes 14 | 15 | ;; primitive collection 16 | (def coll-prop (.-coll lezer-clj/props)) 17 | ;; prefix collection - a prefix token that wraps the next element 18 | (def prefix-coll-prop (.-prefixColl lezer-clj/props)) 19 | ;; the prefix edge itself 20 | (def prefix-edge-prop (.-prefixEdge lezer-clj/props)) 21 | ;; prefix form - pair of [metadata, target] 22 | (def prefix-container-prop (.-prefixContainer lezer-clj/props)) 23 | ;; edges at the beginning/end of collections, + "same" edges (string quotes) 24 | (def start-edge-prop (.-closedBy lz-tree/NodeProp)) 25 | (def end-edge-prop (.-openedBy lz-tree/NodeProp)) 26 | (def same-edge-prop (.-sameEdge lezer-clj/props )) 27 | 28 | ;; used when instantiating the parser 29 | (defn node-prop [prop-name] 30 | (case prop-name "prefixColl" prefix-coll-prop 31 | "coll" coll-prop 32 | "prefixEdge" prefix-edge-prop 33 | "prefixContainer" prefix-container-prop 34 | "sameEdge" same-edge-prop)) 35 | 36 | ;; these wrapping functions exist mainly to avoid type hints 37 | ;; & are mostly compiled away 38 | 39 | (defn ^lz-tree/NodeType type [^js node] (.-type node)) 40 | 41 | (defn ^number start [^js node] 42 | {:pre [(.-from node)]} 43 | (.-from node)) 44 | 45 | (defn ^number end [^js node] 46 | {:pre [(.-to node)]} 47 | (.-to node)) 48 | 49 | ;; a more zipper-like interface 50 | (defn ^js up [node] (.-parent ^js node)) 51 | 52 | (defn ^js down [node] 53 | {:pre [(not (fn? (.-lastChild ^js node)))]} 54 | (.-firstChild ^js node)) 55 | 56 | (defn ^js down-last [node] 57 | {:pre [(not (fn? (.-lastChild ^js node)))]} 58 | (.-lastChild ^js node)) 59 | 60 | (defn ^number depth [^js node] 61 | (loop [node node 62 | i 0] 63 | (if-some [parent (up node)] 64 | (recur parent (inc i)) 65 | i))) 66 | 67 | (defn ^js left [^js node] 68 | (.childBefore (up node) (start node)) 69 | #_(.-prevSibling node)) 70 | 71 | (defn lefts [node] 72 | (take-while identity (iterate left (left node)))) 73 | 74 | (defn ^js right [node] 75 | (.childAfter (up node) (end node)) 76 | #_(.-nextSibling node)) 77 | 78 | (defn rights [node] 79 | (take-while identity (iterate right (right node)))) 80 | 81 | ;; category predicates 82 | 83 | (defn coll-type? [^js node-type] 84 | (or (.prop node-type coll-prop) 85 | #_(.prop node-type prefix-coll-prop))) 86 | 87 | (defn ^boolean prefix-type? [node-type] (.prop ^js node-type prefix-coll-prop)) 88 | (defn ^boolean prefix-edge-type? [node-type] (.prop ^js node-type prefix-edge-prop)) 89 | (defn ^boolean prefix-container-type? [node-type] (.prop ^js node-type prefix-container-prop)) 90 | (defn ^boolean same-edge-type? [node-type] (.prop ^js node-type same-edge-prop)) 91 | (defn ^boolean start-edge-type? [node-type] (.prop ^js node-type start-edge-prop)) 92 | (defn ^boolean end-edge-type? [node-type] (.prop ^js node-type end-edge-prop)) 93 | (defn ^boolean top-type? [node-type] (.-isTop ^js node-type)) 94 | (defn ^boolean error-type? [node-type] (.-isError ^js node-type)) 95 | 96 | (defn ^boolean prefix? [n] (prefix-type? (type n))) 97 | (defn ^boolean prefix-edge? [n] (prefix-edge-type? (type n))) 98 | (defn ^boolean prefix-container? [n] (prefix-container-type? (type n))) 99 | (defn ^boolean same-edge? [n ](same-edge-type? (type n))) 100 | (defn ^boolean start-edge? [n] 101 | (start-edge-type? (type n))) 102 | (defn ^boolean end-edge? [n] (end-edge-type? (type n))) 103 | 104 | (defn ^boolean left-edge-type? [t] 105 | (or (start-edge-type? t) 106 | (same-edge-type? t) 107 | (prefix-edge-type? t))) 108 | 109 | (defn ^boolean left-edge? [n] 110 | (left-edge-type? (type n))) 111 | 112 | (defn ^boolean right-edge-type? [t] 113 | (or (end-edge-type? t) 114 | (same-edge-type? t))) 115 | 116 | (defn ^boolean right-edge? [n] 117 | (right-edge-type? (type n))) 118 | 119 | (defn ^boolean edge? [n] 120 | (let [t (type n)] 121 | (or (start-edge-type? t) 122 | (end-edge-type? t) 123 | (same-edge-type? t) 124 | (prefix-type? t)))) 125 | 126 | (defn closed-by [n] 127 | (some-> (.prop (type n) (.-closedBy lz-tree/NodeProp)) 128 | (aget 0))) 129 | (defn opened-by [n] 130 | (some-> (.prop (type n) (.-openedBy lz-tree/NodeProp)) 131 | (aget 0))) 132 | 133 | (defn ^string name [^js node] (.-name node)) 134 | 135 | ;; specific node types 136 | 137 | (defn error? [^js node] (error-type? node)) 138 | (defn top? [node] (top-type? (type node))) 139 | 140 | (defn program? [node] (identical? "Program" (name node))) 141 | (defn string? [node] (identical? "String" (name node))) 142 | (defn regexp? [node] (identical? "RegExp" (name node))) 143 | (defn line-comment? [node] (identical? "LineComment" (name node))) 144 | (defn discard? [node] (identical? "Discard" (name node))) 145 | 146 | (comment 147 | ;; find a node type id at load time, maybe faster checks? 148 | (some #(and (.is % "Program") (.-id %)) 149 | (.. lezer-clj -parser -nodeSet -types))) 150 | 151 | (defn coll? [node] 152 | (coll-type? (type node))) 153 | 154 | (defn terminal-type? [^js node-type] 155 | (cond (top-type? node-type) false 156 | (.prop node-type prefix-coll-prop) false 157 | (.prop node-type coll-prop) false 158 | (identical? "Meta" (name node-type)) false 159 | (identical? "TaggedLiteral" (name node-type)) false 160 | (identical? "ConstructorCall" (name node-type)) false 161 | :else true)) 162 | 163 | (j/defn balanced? [^:js {:as _node :keys [^js firstChild ^js lastChild]}] 164 | (if-let [closing (closed-by firstChild)] 165 | (and (= closing (name lastChild)) 166 | (not= (end firstChild) (end lastChild))) 167 | true)) 168 | 169 | (defn ancestors [^js node] 170 | (when-some [parent (up node)] 171 | (cons parent 172 | (lazy-seq (ancestors parent))))) 173 | 174 | (defn ^js closest [node pred] 175 | (if (pred node) 176 | node 177 | (reduce (fn [_ x] 178 | (if (pred x) (reduced x) nil)) nil (ancestors node)))) 179 | 180 | (defn ^js highest [node pred] 181 | (reduce (fn [found x] 182 | (if (pred x) x (reduced found))) nil (cons node (ancestors node)))) 183 | 184 | (defn children 185 | ([^js parent from dir] 186 | (when-some [^js child (case dir 1 (.childAfter parent from) 187 | -1 (.childBefore parent from))] 188 | (cons child (lazy-seq 189 | (children parent (case dir 1 (end child) 190 | -1 (start child)) dir))))) 191 | ([^js subtree] 192 | (children subtree (start subtree) 1))) 193 | 194 | (defn eq? [^js x ^js y] 195 | (and (== (start x) (start y)) 196 | (== (end x) (end y)) 197 | (== (depth x) (depth y)))) 198 | 199 | (defn empty? 200 | "Node only contains whitespace" 201 | [^js node] 202 | (let [type-name (name node)] 203 | (cond (coll? node) 204 | (eq? (-> node down right) (-> node down-last)) 205 | 206 | (= "String" type-name) 207 | (== (-> node down end) (-> node down-last start)) 208 | :else false))) 209 | 210 | (defn from-to 211 | ([from to] #js{:from from :to to}) 212 | ([node] 213 | (from-to (start node) (end node)))) 214 | 215 | (defn range [node] 216 | (sel/range (start node) (end node))) 217 | 218 | (defn string 219 | ([^js state node] 220 | (string state (start node) (end node))) 221 | ([^js state from to] 222 | (.sliceString (.-doc state) from to \newline))) 223 | 224 | (defn ancestor? [parent child] 225 | (boolean 226 | (and (<= (start parent) (start child)) 227 | (>= (end parent) (end child)) 228 | (< (depth parent) (depth child))))) 229 | 230 | (defn move-toward 231 | "Returns next loc moving toward `to-path`, skipping children" 232 | [node to-node] 233 | (when-not (eq? node to-node) 234 | (case (compare (start to-node) (start node)) 235 | 0 (cond (ancestor? to-node node) (up node) 236 | (ancestor? node to-node) (down node)) 237 | -1 (if (ancestor? node to-node) 238 | (down-last node) 239 | (or (left node) 240 | (up node))) 241 | 1 (if (ancestor? node to-node) 242 | (down node) 243 | (or (right node) 244 | (up node)))))) 245 | 246 | (defn nodes-between [node to-node] 247 | (take-while identity (iterate #(move-toward % to-node) node))) 248 | 249 | (defn- require-balance? [node] 250 | (or (coll? node) 251 | (string? node) 252 | (regexp? node))) 253 | 254 | (defn ^js tree 255 | "Returns a (Tree https://lezer.codemirror.net/docs/ref/#common.Tree) for editor state 256 | or the SyntaxNode at pos. 257 | 258 | If pos is given and we're using Clojure language support embedded in other languages (e.g. markdown) 259 | enters overlaid Clojure nodes (https://lezer.codemirror.net/docs/ref/#common.MountedTree)." 260 | ([^js state] (language/syntaxTree state)) 261 | ([^js state pos] (-> state language/syntaxTree (.resolveInner pos))) 262 | ([^js state pos dir] (-> state language/syntaxTree (.resolveInner pos dir)))) 263 | 264 | (defn ^js cursor 265 | ([^js tree] (.cursor tree)) 266 | ([^js tree pos] (.cursorAt tree pos)) 267 | ([^js tree pos dir] (.cursorAt tree pos dir))) 268 | 269 | (defn ^js terminal-cursor 270 | [^js tree pos dir] 271 | (loop [i pos] 272 | (let [^js c (cursor tree i dir) 273 | type (.-type c)] 274 | (cond (top-type? type) nil 275 | (terminal-type? (.-type c)) c 276 | :else (recur (+ dir i)))))) 277 | 278 | (defn ^js up-here 279 | "Returns topmost node at same starting position" 280 | [node] 281 | (let [from (start node)] 282 | (or (highest node #(= from (start %))) 283 | node))) 284 | 285 | (defn topmost-cursor [state from] 286 | (-> (tree state from 1) .-node up-here .cursor)) 287 | 288 | (defn terminal-nodes [state from to] 289 | (let [^js cursor (topmost-cursor state from)] 290 | (loop [found []] 291 | (let [node-type (type cursor)] 292 | (cond (> (start cursor) to) found 293 | (or (terminal-type? node-type) 294 | (error? node-type)) 295 | (let [found (conj found #js{:type node-type 296 | :from (start cursor) 297 | :to (end cursor)})] 298 | (.lastChild cursor) 299 | (if (.next cursor) 300 | (recur found) 301 | found)) 302 | :else (if (.next cursor) 303 | (recur found) 304 | found)))))) 305 | 306 | (j/defn balanced-range 307 | ([state ^js node] (balanced-range state (start node) (end node))) 308 | ([state from to] 309 | (let [[from to] (sort [from to]) 310 | from-node (tree state from 1) 311 | to-node (tree state to -1) 312 | from (if (require-balance? from-node) 313 | (start from-node) 314 | from) 315 | to (if (require-balance? to-node) 316 | (end to-node) 317 | to) 318 | [left right] (->> (nodes-between from-node to-node) 319 | (map #(cond-> % (edge? %) up)) 320 | (reduce (fn [[left right] ^js node-between] 321 | [(if (ancestor? node-between from-node) (start node-between) left) 322 | (if (ancestor? node-between to-node) (end node-between) right)]) 323 | [from to]))] 324 | (sel/range left right)))) 325 | 326 | (j/defn inner-span 327 | "Span of collection not including edges" 328 | [^:js {:as node :keys [firstChild lastChild]}] 329 | #js{:from (if (left-edge? firstChild) 330 | (end firstChild) 331 | (start node)) 332 | :to (if (right-edge? lastChild) 333 | (start lastChild) 334 | (end node))}) 335 | 336 | (defn within?< "within (exclusive of edges)" 337 | [parent child] 338 | (let [c1 (compare (start parent) (start child)) 339 | c2 (compare (end parent) (end child))] 340 | (and (or (pos? c1) (neg? c2)) 341 | (not (neg? c1)) 342 | (not (pos? c2))))) 343 | 344 | (defn within? "within (inclusive of edges)" 345 | [parent child] 346 | (and (not (neg? (compare (start parent) (start child)))) 347 | (not (pos? (compare (end parent) (end child)))))) 348 | 349 | (defn follow-edges [node] 350 | (if (edge? node) 351 | (up node) 352 | node)) 353 | 354 | (defn prefix [node] 355 | (when-some [parent (up node)] 356 | (or (u/guard parent prefix-container?) 357 | (u/guard (down parent) prefix-edge?)))) 358 | 359 | (defn left-edge-with-prefix [state node] 360 | (str (some->> (prefix node) (string state)) 361 | (name (down node)))) 362 | 363 | (defn with-prefix [node] 364 | (cond-> node 365 | (prefix node) up)) 366 | 367 | (defn node| 368 | "Node ending immediately to the left of pos" 369 | [state pos] 370 | (some-> (tree state pos -1) 371 | (u/guard #(= pos (end %))))) 372 | 373 | (defn |node 374 | "Node starting immediately to the right of pos" 375 | [state pos] 376 | (some-> (tree state pos 1) 377 | (u/guard #(= pos (start %))))) 378 | 379 | (defn nearest-touching [^js state pos dir] 380 | (let [L (some-> (tree state pos -1) 381 | (u/guard (j/fn [^:js {:keys [to]}] (= pos to)))) 382 | R (some-> (tree state pos 1) 383 | (u/guard (j/fn [^:js {:keys [from]}] 384 | (= pos from)))) 385 | mid (tree state pos)] 386 | (case dir 1 (or (u/guard R (every-pred some? #(or (same-edge? %) (not (right-edge? %))))) 387 | L 388 | R 389 | mid) 390 | -1 (or (u/guard L (every-pred some? #(or (same-edge? %) (not (left-edge? %))))) 391 | R 392 | L 393 | mid)))) 394 | 395 | (defn embedded? 396 | "State position is inside fenced code blocks regardless of clojure syntax." 397 | ([state] (embedded? state (.. state -selection -main -head))) 398 | ([state pos] 399 | (identical? (.-FencedCode lezer-markdown/parser.nodeTypes) 400 | (.. state -tree (resolve pos) -type -id)))) 401 | 402 | (defn within-program? 403 | "Returns true when position (or current cursor) is inside some Clojure node. 404 | 405 | This is useful for limiting certain actions when clojure is nested into another language (e.g. Markdown)" 406 | ([state] (within-program? state (.. state -selection -main -head))) 407 | ([state pos] 408 | (let [n (tree state pos)] 409 | (or (program? n) 410 | (some program? (ancestors n)))))) 411 | 412 | (comment 413 | ;; test state overlaid nodes 414 | (let [state 415 | (nextjournal.clojure-mode.test-utils/make-state 416 | #js [nextjournal.livedoc/markdown-language-support] 417 | "```\n(| a)\n```")] 418 | 419 | (js/console.log (-> (tree state 4 1) .-node up-here .cursor)) 420 | (js/console.log (terminal-nodes state 4 9)))) 421 | --------------------------------------------------------------------------------