diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b3567..799bda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## 0.0.3 (Dec 20, 2022) + - Added ParEdit-style structural editing + - Added format-on-type (incl. auto-indentation) + - Bug fixes + ## 0.0.2 (Nov 24, 2022) - Improved syntax highlighting. diff --git a/README.md b/README.md index c4028b8..85e1160 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,18 @@ Features: - [x] Basic Hy code snippets - [x] Code evaluation shortcuts - [x] Improved syntax highlighting +- [x] [Paredit](https://www.emacswiki.org/emacs/ParEdit)-style structural editing based on S-expressions (slurping, barfing, dragging, killing, rewrapping, splicing, raising, navigation, auto-balancing for parens and other wrappers `[({""})]`) +- [x] Auto-formatting on edit (esp. auto-indentation) Planned features: -- [ ] Auto-formatting on edit (esp. auto-indentation) -- [ ] [Paredit](https://www.emacswiki.org/emacs/ParEdit)-style structural editing based on S-expressions (slurping, barfing, dragging, killing, rewrapping, splicing, raising, navigation, auto-balancing for parens and other wrappers `[({""})]`) - [ ] Intellisense code completion for built-in Hy functions and macros -## Installation +## How to Install + +### VS Code Extension Marketplace + +1. Navigate to the VS Code Extension marketplace within VS Code. +2. Search for "vscode-hy (hylang official)" and install as usual. ### Local Install @@ -26,7 +31,6 @@ Planned features: 2. Clone this repo within that directory (e.g. `git clone https://www.github.com/hylang/vscode-hy`) 3. Reload or relaunch any open VS Code/VS Codium windows - ## Contribution Issues and pull requests welcome. diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..1ccc59b --- /dev/null +++ b/deps.edn @@ -0,0 +1,7 @@ +{:deps {zprint/zprint {:mvn/version "1.2.2"} + cljfmt/cljfmt {:mvn/version "0.8.0"} + thheller/shadow-cljs {:mvn/version "2.18.0"} + org.clojars.liverm0r/dartclojure {:mvn/version "0.1.10-SNAPSHOT"} + #_#_org.clojars.liverm0r/dartclojure {:local/root "../DartClojure"}} + :paths ["src/cljs-lib/src" + "src/cljs-lib/test"]} \ No newline at end of file diff --git a/package.json b/package.json index 6a499a4..45c6a79 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "hy", "syntax" ], - "version": "0.0.2", + "version": "0.0.3", "publisher": "hylang", "icon": "images/hy-logo-small.png", - "main": "./out/src/hyMain", + "main": "./out/hyMain", "license": "MIT", "author": { "name": "Caleb Figgers" @@ -57,6 +57,98 @@ "path": "./syntaxes/hy.tmLanguage.json" } ], + "configurationDefaults": { + "[hy]": { + "editor.wordSeparators": "\t ()\"':,;~@#$%^&{}[]`", + "editor.autoClosingBrackets": "always", + "editor.autoClosingQuotes": "always", + "editor.formatOnType": true, + "editor.autoIndent": "full", + "editor.formatOnPaste": true, + "files.trimTrailingWhitespace": false, + "editor.matchBrackets": "never", + "editor.guides.indentation": false, + "editor.parameterHints.enabled": false, + "editor.unicodeHighlight.allowedCharacters": { + " ": true, + "꞉": true + } + } + }, + "configuration": [ + { + "type": "object", + "title": "Hy", + "properties": { + "hy.keybindingsEnabled": { + "type": "boolean", + "description": "Activate keybindings.", + "default": true, + "scope": "window" + } + } + }, + { + "type": "object", + "title": "Paredit", + "properties": { + "hy.paredit.defaultKeyMap": { + "type": "string", + "description": "The default keymap to use for bindings when there is no custom binding.", + "default": "strict", + "enum": [ + "original", + "strict", + "none" + ], + "scope": "window" + }, + "hy.paredit.hijackVSCodeDefaults": { + "type": "boolean", + "markdownDescription": "When enabled, more VS Code built-in shortcuts are overridden with their ”corresponding” Paredit commands.", + "default": true, + "scope": "window" + }, + "hy.paredit.strictPreventUnmatchedClosingBracket": { + "type": "boolean", + "markdownDescription": "Experimental: Prevents you from entering unmatched closing brackets when in `strict` mode. (Does not work when there is an active selection.)", + "default": false, + "scope": "window" + }, + "hy.paredit.killAlsoCutsToClipboard": { + "type": "boolean", + "markdownDescription": "When enabled, replaces the clipboard content with the deleted code.", + "default": false, + "scope": "window" + } + } + }, + { + "title": "Calva-fmt", + "type": "object", + "properties": { + "hy.calva.fmt.configPath": { + "type": "string", + "markdownDescription": "Path to [cljfmt](https://github.com/weavejester/cljfmt#configuration) configuration file. Absolute or relative to the project root directory. To provide the config via [clojure-lsp](https://clojure-lsp.io), set this to `CLOJURE-LSP` (case sensitive)." + }, + "hy.calva.fmt.formatAsYouType": { + "type": "boolean", + "default": true, + "description": "Auto-adjust indentation and format as you enter new lines." + }, + "hy.calva.fmt.newIndentEngine": { + "type": "boolean", + "default": true, + "markdownDescription": "Use the structural editor for indentation (instead of `cljfmt`)." + }, + "hy.calva.fmt.keepCommentTrailParenOnOwnLine": { + "type": "boolean", + "default": true, + "markdownDescription": "Treat `(comment...)` forms special and keep its closing paren on a line of its own." + } + } + } + ], "commands": [ { "command": "hy.startREPL", @@ -69,6 +161,402 @@ { "command": "hy.evalFile", "title": "Hy: Evaluate file" + }, + { + "command": "hy.continueComment", + "title": "Continue Comment (add a commented line below).", + "category": "Hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.togglemode", + "title": "Toggle Paredit Mode", + "when": "editorLangId == hy && paredit:keyMap =~ /original|strict/", + "enablement": "editorLangId == hy && paredit:keyMap =~ /original|strict/" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.forwardSexp", + "title": "Move Cursor Forward Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.backwardSexp", + "title": "Move Cursor Backward Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.forwardSexpOrUp", + "title": "Move Cursor Forward or Up Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.backwardSexpOrUp", + "title": "Move Cursor Backward or Up Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.forwardDownSexp", + "title": "Move Cursor Forward Down Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.backwardDownSexp", + "title": "Move Cursor Backward Down Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.backwardUpSexp", + "title": "Move Cursor Backward Up Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.forwardUpSexp", + "title": "Move Cursor Forward Up Sexp/Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.closeList", + "title": "Move Cursor Forward to List End/Close", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectForwardSexp", + "title": "Select Forward Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectRight", + "title": "Select Right", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectBackwardSexp", + "title": "Select Backward Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectForwardDownSexp", + "title": "Select Forward Down Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectBackwardDownSexp", + "title": "Select Backward Down Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectBackwardUpSexp", + "title": "Select Backward Up Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectForwardUpSexp", + "title": "Select Forward Up Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectBackwardSexpOrUp", + "title": "Select Backward Or Up Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectForwardSexpOrUp", + "title": "Select Forward Or Up Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectCloseList", + "title": "Select Forward to List End/Close", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.selectOpenList", + "title": "Select Backward to List Start/Open", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.rangeForDefun", + "title": "Select Current Top Level (aka defun) Form", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.sexpRangeExpansion", + "title": "Expand Selection", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.sexpRangeContraction", + "title": "Shrink Selection", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.slurpSexpForward", + "title": "Slurp Sexp Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.slurpSexpBackward", + "title": "Slurp Sexp Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.barfSexpForward", + "title": "Barf Sexp Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.barfSexpBackward", + "title": "Barf Sexp Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.spliceSexp", + "title": "Splice Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.splitSexp", + "title": "Split Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.joinSexp", + "title": "Join Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.raiseSexp", + "title": "Raise Sexp", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.transpose", + "title": "Transpose (Swap) the two Sexps Around the Cursor", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.dragSexprBackward", + "title": "Drag Sexp Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.dragSexprForward", + "title": "Drag Sexp Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.dragSexprBackwardUp", + "title": "Drag Sexp Backward Up", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.dragSexprForwardDown", + "title": "Drag Sexp Forward Down", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.dragSexprForwardUp", + "title": "Drag Sexp Forward Up", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.dragSexprBackwardDown", + "title": "Drag Sexp Backward Down", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.convolute", + "title": "Convolute Sexp ¯\\_(ツ)_/¯", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.killRight", + "title": "Kill/Delete Right", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.killSexpForward", + "title": "Kill/Delete Sexp Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.killSexpBackward", + "title": "Kill/Delete Sexp Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.killListForward", + "title": "Kill/Delete Forward to End of List", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.killListBackward", + "title": "Kill/Delete Backward to Start of List", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.spliceSexpKillForward", + "title": "Splice & Kill/Delete Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.spliceSexpKillBackward", + "title": "Splice & Kill/Delete Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.deleteForward", + "title": "Delete Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.deleteBackward", + "title": "Delete Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.forceDeleteForward", + "title": "Force Delete Forward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.forceDeleteBackward", + "title": "Force Delete Backward", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.wrapAroundParens", + "title": "Wrap Around ()", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.wrapAroundSquare", + "title": "Wrap Around []", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.wrapAroundCurly", + "title": "Wrap Around {}", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.wrapAroundQuote", + "title": "Wrap Around \"\"", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.rewrapParens", + "title": "Rewrap ()", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.rewrapSquare", + "title": "Rewrap []", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.rewrapCurly", + "title": "Rewrap {}", + "enablement": "editorLangId == hy" + }, + { + "category": "Hy Paredit", + "command": "hy.paredit.rewrapQuote", + "title": "Rewrap \"\"", + "enablement": "editorLangId == hy" + }, + { + "command": "hy.calva-fmt.formatCurrentForm", + "title": "Format Current Form", + "category": "Hy Format", + "enablement": "editorLangId == hy" + }, + { + "command": "hy.calva-fmt.alignCurrentForm", + "title": "Format and Align Current Form (recursively, experimental)", + "category": "Hy Format", + "enablement": "editorLangId == hy" + }, + { + "command": "hy.calva-fmt.trimCurrentFormWhiteSpace", + "title": "Format Current Form and trim space between forms", + "category": "Hy Format", + "enablement": "editorLangId == hy" + }, + { + "command": "hy.calva-fmt.inferParens", + "title": "Infer Parens (from the indentation)", + "category": "Hy Format", + "enablement": "editorLangId == hy" + }, + { + "command": "hy.calva-fmt.tabIndent", + "title": "Indent Line", + "category": "Hy Format", + "enablement": "editorLangId == hy" + }, + { + "command": "hy.calva-fmt.tabDedent", + "title": "Dedent Line", + "category": "Hy Format", + "enablement": "editorLangId == hy" } ], "keybindings": [ @@ -79,6 +567,358 @@ { "command": "hy.evalFile", "key": "ctrl+alt+enter" + }, + { + "command": "hy.paredit.togglemode", + "key": "ctrl+alt+p ctrl+alt+m", + "when": "editorLangId == hy && hy:keybindingsEnabled && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.backwardSexp", + "mac": "ctrl+left", + "win": "alt+left", + "linux": "alt+left", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && !config.hy.paredit.hijackVSCodeDefaults && !hy:cursorInComment || hy:cursorBeforeComment" + }, + { + "command": "hy.paredit.backwardSexp", + "mac": "alt+left", + "win": "ctrl+left", + "linux": "ctrl+left", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.hy.paredit.hijackVSCodeDefaults && !hy:cursorInComment || hy:cursorBeforeComment" + }, + { + "command": "hy.paredit.forwardSexp", + "mac": "ctrl+right", + "win": "alt+right", + "linux": "alt+right", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && !config.hy.paredit.hijackVSCodeDefaults && !hy:cursorInComment || hy:cursorAfterComment" + }, + { + "command": "hy.paredit.forwardSexp", + "mac": "alt+right", + "win": "ctrl+right", + "linux": "ctrl+right", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.hy.paredit.hijackVSCodeDefaults && !hy:cursorInComment || hy:cursorAfterComment" + }, + { + "command": "hy.paredit.forwardDownSexp", + "key": "ctrl+down", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.backwardDownSexp", + "key": "ctrl+alt+up", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.forwardUpSexp", + "key": "ctrl+alt+down", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.backwardUpSexp", + "key": "ctrl+up", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.closeList", + "key": "ctrl+end", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.openList", + "key": "ctrl+home", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectForwardSexp", + "mac": "shift+alt+right", + "win": "shift+ctrl+right", + "linux": "shift+ctrl+right", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectRight", + "mac": "ctrl+shift+k", + "win": "ctrl+k ctrl+shift+k", + "linux": "ctrl+k ctrl+shift+k", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !selectionAnchorSet && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectBackwardSexp", + "mac": "shift+alt+left", + "win": "shift+ctrl+left", + "linux": "shift+ctrl+left", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectForwardDownSexp", + "key": "ctrl+shift+down", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectBackwardDownSexp", + "key": "ctrl+shift+alt+up", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectForwardUpSexp", + "key": "ctrl+shift+alt+down", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectBackwardUpSexp", + "key": "ctrl+shift+up", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectCloseList", + "key": "ctrl+shift+end", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.selectOpenList", + "key": "ctrl+shift+home", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.rangeForDefun", + "key": "ctrl+alt+w space", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.sexpRangeExpansion", + "mac": "ctrl+w", + "win": "shift+alt+right", + "linux": "shift+alt+right", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && !hy:cursorInComment" + }, + { + "command": "hy.paredit.sexpRangeContraction", + "mac": "ctrl+shift+w", + "win": "shift+alt+left", + "linux": "shift+alt+left", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && !hy:cursorInComment" + }, + { + "command": "hy.paredit.slurpSexpForward", + "key": "ctrl+alt+win+right", + "linux": "ctrl+alt+.", + "when": "hy:keybindingsEnabled && editorLangId == hy" + }, + { + "command": "hy.paredit.slurpSexpBackward", + "key": "ctrl+alt+win+left", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.barfSexpForward", + "key": "ctrl+alt+shift+left", + "linux": "ctrl+alt+,", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.barfSexpBackward", + "key": "ctrl+alt+shift+right", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.spliceSexp", + "key": "ctrl+alt+s", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.splitSexp", + "key": "ctrl+shift+s", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.joinSexp", + "key": "ctrl+shift+j", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.raiseSexp", + "key": "ctrl+alt+p ctrl+alt+r", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.transpose", + "key": "ctrl+alt+t", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.dragSexprBackward", + "key": "ctrl+shift+alt+b", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && !hy:cursorInComment" + }, + { + "command": "hy.paredit.dragSexprForward", + "key": "ctrl+shift+alt+f", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && !hy:cursorInComment" + }, + { + "command": "hy.paredit.dragSexprBackward", + "key": "alt+up", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.hy.paredit.hijackVSCodeDefaults && !hy:cursorInComment" + }, + { + "command": "hy.paredit.dragSexprForward", + "key": "alt+down", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.hy.paredit.hijackVSCodeDefaults && !hy:cursorInComment" + }, + { + "command": "hy.paredit.dragSexprBackwardUp", + "key": "ctrl+shift+alt+u", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.dragSexprForwardDown", + "key": "ctrl+shift+alt+d", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.dragSexprForwardUp", + "key": "ctrl+shift+alt+k", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.dragSexprBackwardDown", + "key": "ctrl+shift+alt+j", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.convolute", + "key": "ctrl+shift+c", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.killRight", + "mac": "ctrl+k", + "win": "ctrl+k ctrl+k", + "linux": "ctrl+k ctrl+k", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !selectionAnchorSet && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.killSexpForward", + "key": "ctrl+shift+delete", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.killSexpBackward", + "key": "ctrl+alt+backspace", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.killListForward", + "key": "ctrl+delete", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.killListBackward", + "key": "ctrl+backspace", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.spliceSexpKillForward", + "key": "ctrl+alt+shift+delete", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.spliceSexpKillBackward", + "key": "ctrl+alt+shift+backspace", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.wrapAroundParens", + "key": "ctrl+alt+shift+p", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.wrapAroundSquare", + "key": "ctrl+alt+shift+s", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.wrapAroundCurly", + "key": "ctrl+alt+shift+c", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.wrapAroundQuote", + "key": "ctrl+alt+shift+q", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.rewrapParens", + "key": "ctrl+alt+r ctrl+alt+p", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.rewrapSquare", + "key": "ctrl+alt+r ctrl+alt+s", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.rewrapCurly", + "key": "ctrl+alt+r ctrl+alt+c", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.rewrapQuote", + "key": "ctrl+alt+r ctrl+alt+q", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, + { + "command": "hy.paredit.deleteForward", + "key": "delete", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap == strict && !editorReadOnly && !editorHasMultipleSelections && !hy:cursorInComment" + }, + { + "command": "hy.paredit.deleteBackward", + "key": "backspace", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap == strict && !editorReadOnly && !editorHasMultipleSelections && !hy:cursorInComment" + }, + { + "command": "hy.paredit.forceDeleteForward", + "key": "alt+delete", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap == strict && !editorReadOnly && !editorHasMultipleSelections" + }, + { + "command": "hy.paredit.forceDeleteBackward", + "key": "alt+backspace", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && paredit:keyMap == strict && !editorReadOnly && !editorHasMultipleSelections" + }, + { + "command": "hy.continueComment", + "key": "enter", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && hy:cursorInComment" + }, + { + "command": "hy.calva-fmt.formatCurrentForm", + "key": "tab", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !editorReadOnly && !inSnippetMode && !suggestWidgetVisible && !hasOtherSuggestions && !inSnippetMode && !inlineSuggestionVisible" + }, + { + "command": "hy.calva-fmt.alignCurrentForm", + "key": "ctrl+alt+l", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !editorReadOnly && !suggestWidgetVisible && !hasOtherSuggestions" + }, + { + "command": "hy.calva-fmt.inferParens", + "key": "ctrl+alt+p i", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !editorReadOnly && !suggestWidgetVisible && !hasOtherSuggestions" + }, + { + "command": "hy.calva-fmt.tabIndent", + "key": "ctrl+i", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !editorReadOnly && !suggestWidgetVisible && !hasOtherSuggestions" + }, + { + "command": "hy.calva-fmt.tabDedent", + "key": "shift+tab", + "when": "hy:keybindingsEnabled && editorLangId == hy && editorTextFocus && !editorReadOnly && !suggestWidgetVisible && !hasOtherSuggestions" } ], "snippets": [ @@ -91,15 +931,23 @@ "scripts": { "vscode:prepublish": "tsc -p ./", "compile": "tsc -watch -p ./", - "postinstall": "node ./node_modules/vscode/bin/install", "test": "node ./node_modules/vscode/bin/test" }, + "dependencies": { + "@types/universal-analytics": "^0.4.2", + "acorn": "^6.4.1", + "immutable": "3.8.1", + "immutable-cursor": "2.0.1", + "lodash": "^4.17.19", + "lodash.isequal": "4.5.0", + "parinfer": "^3.12.0", + "universal-analytics": "^0.5.3", + "uuidv4": "6.2.12" + }, "devDependencies": { - "@types/mocha": "^2.2.32", "@types/node": "^12.12.0", "@types/vscode": "^1.34.0", "mocha": "^10.1.0", - "typescript": "^4.0.0", - "vscode": "^1.0.0" + "typescript": "^4.7.4" } } diff --git a/shadow-cljs.edn b/shadow-cljs.edn new file mode 100644 index 0000000..b6f5bc7 --- /dev/null +++ b/shadow-cljs.edn @@ -0,0 +1,29 @@ +{:deps true + + :builds {:calva-lib + {:target :node-library + :exports {:formatText calva.fmt.formatter/format-text-bridge + :formatTextAtRange calva.fmt.formatter/format-text-at-range-bridge + :formatTextAtIdx calva.fmt.formatter/format-text-at-idx-bridge + :formatTextAtIdxOnType calva.fmt.formatter/format-text-at-idx-on-type-bridge + :cljfmtOptionsFromString calva.fmt.formatter/merge-cljfmt-from-string-js-bridge + :inferIndents calva.fmt.inferer/infer-indents-bridge + :inferParens calva.fmt.inferer/infer-parens-bridge + :jsify calva.js-utils/jsify + :cljify calva.js-utils/cljify + :prettyPrint calva.pprint.printer/pretty-print-js-bridge + :parseEdn calva.parse/parse-edn-js-bridge + :parseForms calva.parse/parse-forms-js-bridge + :setStateValue calva.state/set-state-value! + :getStateValue calva.state/get-state-value + :getState calva.state/get-state + :removeStateValue calva.state/remove-state-value! + :js2cljs calva.js2cljs.converter/convert-bridge + :dart2clj calva.dartclojure/convert-bridge} + :output-to "out/cljs-lib/cljs-lib.js"} + :test + {:target :node-test + :output-to "out/cljs-lib/test/cljs-lib-tests.js" + :ns-regexp "-test$" + :autorun true}}} + diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..3d2702b --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,81 @@ +import * as vscode from 'vscode'; +import * as UA from 'universal-analytics'; +import * as uuid from 'uuidv4'; +import * as os from 'os'; +import { isUndefined } from 'lodash'; + +// var debug = require('debug'); +// debug.log = console.info.bind(console); + +function userAllowsTelemetry(): boolean { + const config = vscode.workspace.getConfiguration('telemetry'); + return config.get('enableTelemetry', false); +} + +export default class Analytics { + private visitor: UA.Visitor; + private extension: vscode.Extension; + private extensionVersion: string; + private store: vscode.Memento; + private GA_ID = (process.env.CALVA_DEV_GA + ? process.env.CALVA_DEV_GA + : 'FUBAR-69796730-3' + ).replace(/^FUBAR/, 'UA'); + + constructor(context: vscode.ExtensionContext) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.extension = vscode.extensions.getExtension('betterthantomorrow.calva')!; + this.extensionVersion = this.extension.packageJSON.version; + this.store = context.globalState; + + this.visitor = UA(this.GA_ID, this.userID()); + this.visitor.set('cd1', this.extensionVersion); + this.visitor.set('cd2', vscode.version); + this.visitor.set('cd3', this.extensionVersion); + this.visitor.set('cd4', `${os.platform()}/${os.release()}`); + this.visitor.set('cn', `calva-${this.extensionVersion}`); + this.visitor.set( + 'ua', + `Calva/${this.extensionVersion} (${os.platform()}; ${os.release()}; ${os.type}) VSCode/${ + vscode.version + }` + ); + } + + private userID(): string { + const KEY = 'userLogID'; + const value = this.store.get(KEY); + if (isUndefined(value)) { + const newID = uuid.uuid(); + void this.store.update(KEY, newID); + return newID; + } else { + return value; + } + } + + logPath(path: string): Analytics { + if (userAllowsTelemetry()) { + this.visitor.pageview(path); + } + return this; + } + + logEvent(category: string, action: string, label?: string, value?: string): Analytics { + if (userAllowsTelemetry()) { + this.visitor.event({ + ec: category, + ea: action, + el: label, + ev: value, + }); + } + return this; + } + + send() { + if (userAllowsTelemetry()) { + this.visitor.send(); + } + } +} diff --git a/src/calva-fmt/.vscodeignore b/src/calva-fmt/.vscodeignore new file mode 100644 index 0000000..d75e9c4 --- /dev/null +++ b/src/calva-fmt/.vscodeignore @@ -0,0 +1,17 @@ +.gitignore +jsconfig.json +tsconfig.json +vsc-extension-quickstart.md +.eslintrc.json +.nrepl-port + +.vscode/** +.vscode-test/** +test/** +cljc/** +lib/** +test_js/** +js/** + +.shadow-cljs/** +atom-language-clojure/** diff --git a/src/calva-fmt/README.md b/src/calva-fmt/README.md new file mode 100644 index 0000000..984ad8f --- /dev/null +++ b/src/calva-fmt/README.md @@ -0,0 +1,89 @@ +# Calva Format + +A Clojure and ClojureScript formatter for Visual Studio Code. + +## Raison d´être + +1. To the extent possible, formatting should happen as you type. Code should very seldom be in a an unformatted state. +1. **Fewer dependencies, less headaches**. You should be able to edit a Clojure file, with full formatting help, without depending on a REPL running or anything else needed to be installed. +1. **Fewer conflicts, more predictability**. As VSCode gets to be a more serious editor for Clojurians there is a an editing war going on between the various plugins that help with editing Clojure code. Calva Formatter is aiming at being the major Clojure formatter, lifting this responsibility from the shoulders of extensions like Calva, Paredit and other Clojure related extensions. + +## Features + +* Formats according to the community [Clojure Style Guide](https://github.com/bbatsov/clojure-style-guide) (while giving you some options to tweak this style). +* Formats the code when new lines are entered, mostly keeping things formated as you type. +* Adds a command for formatting the enclosing form, default key binding is `tab`. +* Adds a command for aligning map items, and bindings in the current form, default key binding `ctrl+alt+l`. (This is a bit experimental and will not always produce the prettiest results. Also it is recursive.) You can also opt-in to have this behaviour be on for all formatting, via settings. +* Adds a command for infering parens/brackets from indents (using ParinferLib), default key binding `ctrl+alt+f ctrl+alt+p`. +* Adds a command for indenting and dedenting the current line (using ParinferLib), default key binding `ctrl+i` and `shift+ctrl+i`, respectively. +* Provides the formater for the VSCode *Format Selection* and *Format Document* commands as well as for *Format on Paste*. +* Is intended to be used alongside and by other Clojure extensions. + +### Demo GIF time + +Some examples of what it can be like to use Calva Formatter: + +### Format Current Form + +![Format Current Form](/src/calva-fmt/assets/format-current-form.gif) + +### Align Current Form + +![Align Current Form](/src/calva-fmt/assets/align-items.gif) + +### Parinfer + +![Infer parens](/src/calva-fmt/assets/parinfer.gif) + +## How to use + +Install it and edit away. It will keep the code formatted mostly as you type, in a somewhat ”relaxed” way, and will format it more strictly (collecting trailing brackets, for instance) when you hit `tab`. Search the settings for `calva-fmt` to see how you can tweak it. + + +## You might not need to install it + +*Calva Formatter* comes bundled with [Calva](https://marketplace.visualstudio.com/items?itemName=betterthantomorrow.calva) + +## Written in ClojureScript + +Built with [Shadow CLJS](http://shadow-cljs.org/). + +## By the Calva team a.k.a. Better Than Tomorrow + +We are committed to make the Clojure experience in VS Code productive and pleasurable. + +* [Peter Strömberg](https://github.com/PEZ) +* [Matt Seddon](https://github.com/mseddon) +* You? + + +## Something is not working? + +File issues or send pull requests. You can also find us in the #editors and #calva channels of Clojurians Slack. + + +## Disable the Parinfer Extension + +Calva Formatter and the current Parinfer extension are not compatible. Some Parinfer functionality is is built in, though, in the form of explicit commands, see above feature list. + +## Calva Paredit recommended + +[Calva Paredit](https://marketplace.visualstudio.com/items?itemName=cospaia.paredit-revived) brings great structural editing support to VS Code. + +## How to contribute + +Calva Formater is written in TypeScript and ClojureScript. It is setup so that the formatting ”decisions” are made by a library written in ClojureScript and then TypeScript is used to integrate these decisions into VS Code. Division of labour. + +See [How to Contribute](https://github.com/BetterThanTomorrow/calva-fmt/wiki/How-to-Contribute) on the project wiki for instructions. + +## The Future of calva-fmt + +* Make it honor project settings. +* Offer more pretty printing options. + +## Happy Formatting ❤️ + + +PRs welcome, file an issue or chat us up in the [`#calva` channel](https://clojurians.slack.com/messages/calva/) of the Clojurians Slack. Tweeting [@pappapez](https://twitter.com/pappapez) works too. + +[![#calva in Clojurians Slack](https://img.shields.io/badge/clojurians-calva--dev-blue.svg?logo=slack)](https://clojurians.slack.com/messages/calva/) diff --git a/src/calva-fmt/assets/align-items.gif b/src/calva-fmt/assets/align-items.gif new file mode 100644 index 0000000..ee111a2 Binary files /dev/null and b/src/calva-fmt/assets/align-items.gif differ diff --git a/src/calva-fmt/assets/calva-fmt.png b/src/calva-fmt/assets/calva-fmt.png new file mode 100644 index 0000000..179df10 Binary files /dev/null and b/src/calva-fmt/assets/calva-fmt.png differ diff --git a/src/calva-fmt/assets/format-current-form.gif b/src/calva-fmt/assets/format-current-form.gif new file mode 100644 index 0000000..04b27b1 Binary files /dev/null and b/src/calva-fmt/assets/format-current-form.gif differ diff --git a/src/calva-fmt/assets/infer-parens.gif b/src/calva-fmt/assets/infer-parens.gif new file mode 100644 index 0000000..64007df Binary files /dev/null and b/src/calva-fmt/assets/infer-parens.gif differ diff --git a/src/calva-fmt/assets/parinfer.gif b/src/calva-fmt/assets/parinfer.gif new file mode 100644 index 0000000..06458dd Binary files /dev/null and b/src/calva-fmt/assets/parinfer.gif differ diff --git a/src/calva-fmt/atom-language-clojure/.coffeelintignore b/src/calva-fmt/atom-language-clojure/.coffeelintignore new file mode 100644 index 0000000..1db51fe --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/.coffeelintignore @@ -0,0 +1 @@ +spec/fixtures diff --git a/src/calva-fmt/atom-language-clojure/.travis.yml b/src/calva-fmt/atom-language-clojure/.travis.yml new file mode 100644 index 0000000..20cfe51 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/.travis.yml @@ -0,0 +1,15 @@ +language: objective-c + +notifications: + email: + on_success: never + on_failure: change + +script: 'curl -s https://raw.githubusercontent.com/atom/ci/master/build-package.sh | sh' + +git: + depth: 10 + +branches: + only: + - master diff --git a/src/calva-fmt/atom-language-clojure/LICENSE.md b/src/calva-fmt/atom-language-clojure/LICENSE.md new file mode 100644 index 0000000..6c77a82 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/LICENSE.md @@ -0,0 +1,48 @@ +Copyright (c) 2014 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------- + +This package was derived from a TextMate bundle located at +https://github.com/mmcgrana/textmate-clojure and distributed under the +following license, located in `LICENSE.md`: + +The MIT License (MIT) + +Copyright (c) 2010- Mark McGranaghan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/calva-fmt/atom-language-clojure/README.md b/src/calva-fmt/atom-language-clojure/README.md new file mode 100644 index 0000000..8eec166 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/README.md @@ -0,0 +1,26 @@ +# Calva Syntax Highlight Grammar + +Calva Format's `clojure.tmLanguage.json` file is built from here (the `/grammars/clojure.cson` file). When making changes, also update `spec/clojure-spec.coffee` and make sure all tests pass. + +To run the tests you need to open this directory in Atom and issue the **Run Package Specs** command. + +To test your changes on CLojure source files in Atom you need to link this directory to where Atom keeps its dev package files. This works on Mac and Linux: + +```sh +$ cd ~/.atom/dev/packages +$ ln -s /calva-fmt/atom-language-clojure/ language-clojure +``` + +You also need to run Atom in dev mode: + +```sh +$ atom -d +``` + +When all old and new tests pass, update Calva Format's grammar from the root of the project: + +```sh +$ npm run update-grammar +``` + +Then make some sanity tests on Clojure source files in VS Code (this mainly tests that the update-grammar script works, but anyway). diff --git a/src/calva-fmt/atom-language-clojure/appveyor.yml b/src/calva-fmt/atom-language-clojure/appveyor.yml new file mode 100644 index 0000000..2b0fde4 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/appveyor.yml @@ -0,0 +1,27 @@ +version: "{build}" + +platform: x64 + +branches: + only: + - master + +clone_depth: 10 + +skip_tags: true + +environment: + APM_TEST_PACKAGES: + + matrix: + - ATOM_CHANNEL: stable + - ATOM_CHANNEL: beta + +install: + - ps: Install-Product node 4 + +build_script: + - ps: iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/atom/ci/master/build-package.ps1')) + +test: off +deploy: off diff --git a/src/calva-fmt/atom-language-clojure/coffeelint.json b/src/calva-fmt/atom-language-clojure/coffeelint.json new file mode 100644 index 0000000..a5dd715 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/coffeelint.json @@ -0,0 +1,37 @@ +{ + "max_line_length": { + "level": "ignore" + }, + "no_empty_param_list": { + "level": "error" + }, + "arrow_spacing": { + "level": "error" + }, + "no_interpolation_in_single_quotes": { + "level": "error" + }, + "no_debugger": { + "level": "error" + }, + "prefer_english_operator": { + "level": "error" + }, + "colon_assignment_spacing": { + "spacing": { + "left": 0, + "right": 1 + }, + "level": "error" + }, + "braces_spacing": { + "spaces": 0, + "level": "error" + }, + "spacing_after_comma": { + "level": "error" + }, + "no_stand_alone_at": { + "level": "error" + } +} diff --git a/src/calva-fmt/atom-language-clojure/grammars/clojure.cson b/src/calva-fmt/atom-language-clojure/grammars/clojure.cson new file mode 100644 index 0000000..0a23326 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/grammars/clojure.cson @@ -0,0 +1,424 @@ +'scopeName': 'source.clojure' +'fileTypes': [ + 'boot' + 'clj' + 'clj.hl' + 'cljc' + 'cljs' + 'cljs.hl' + 'cljx' + 'clojure' + 'edn' + 'org' + 'joke' + 'joker' +] +'foldingStartMarker': '\\(\\s*$' +'foldingStopMarker': '^\\s*\\)' +'firstLineMatch': '''(?x) + # Hashbang + ^\\#!.*(?:\\s|\\/) + boot + (?:$|\\s) + | + # Modeline + (?i: + # Emacs + -\\*-(?:\\s*(?=[^:;\\s]+\\s*-\\*-)|(?:.*?[;\\s]|(?<=-\\*-))mode\\s*:\\s*) + clojure(script)? + (?=[\\s;]|(?]?\\d+|m)?|\\sex)(?=:(?=\\s*set?\\s[^\\n:]+:)|:(?!\\s*set?\\s))(?:(?:\\s|\\s*:\\s*)\\w*(?:\\s*=(?:[^\\n\\\\\\s]|\\\\.)*)?)*[\\s:](?:filetype|ft|syntax)\\s*= + clojure + (?=\\s|:|$) + ) +''' +'name': 'Clojure' +'patterns': [ + { + 'include': '#comment' + } + { + 'include': '#shebang-comment' + } + { + 'include': '#prompt' + } + { + 'include': '#quoted-sexp' + } + { + 'include': '#sexp' + } + { + 'include': '#keyfn' + } + { + 'include': '#string' + } + { + 'include': '#vector' + } + { + 'include': '#set' + } + { + 'include': '#map' + } + { + 'include': '#regexp' + } + { + 'include': '#var' + } + { + 'include': '#constants' + } + { + 'include': '#dynamic-variables' + } + { + 'include': '#metadata' + } + { + 'include': '#namespace-symbol' + } + { + 'include': '#symbol' + } +] +'repository': + 'comment': + # NOTE: This must be kept as a begin/end match for language-todo to work + 'begin': '(?\\<\\/\\!\\?\\*]+(?=(\\s|\\)|\\]|\\}|\\,))' + 'name': 'constant.keyword.clojure' + 'keyfn': + 'patterns': [ + { + 'match': '(?<=^|\\s|\\(|\\[|\\{)(if(-[-\\p{Ll}\\?]*)?|when(-[-\\p{Ll}]*)?|for(-[-\\p{Ll}]*)?|cond|do|let(-[-\\p{Ll}\\?]*)?|binding|loop|recur|fn|throw[\\p{Ll}\\-]*|try|catch|finally|([\\p{Ll}]*case))(?=(\\s|\\)|\\]|\\}))' + 'name': 'storage.control.clojure' + } + { + 'match': '(?<=^|\\s|\\(|\\[|\\{)(declare-?|(in-)?ns|import|use|require|load|compile|(def(?!ault)[\\p{Ll}\\-]*))(?=(\\s|\\)|\\]|\\}))' + 'name': 'storage.control.clojure' + } + ] + 'dynamic-variables': + 'match': '\\*[\\w\\.\\-\\_\\:\\+\\=\\>\\<\\!\\?\\d]+\\*' + 'name': 'entity.name.variable.dynamic.clojure' + 'map': + 'begin': '(\\{)' + 'beginCaptures': + '1': + 'name': 'punctuation.section.map.begin.clojure' + 'end': '(\\}(?=[\\}\\]\\)\\s]*(?:;|$)))|(\\})' + 'endCaptures': + '1': + 'name': 'punctuation.section.map.end.trailing.clojure' + '2': + 'name': 'punctuation.section.map.end.clojure' + 'name': 'meta.map.clojure' + 'patterns': [ + { + 'include': '$self' + } + ] + 'metadata': + 'patterns': [ + { + 'begin': '(\\^\\{)' + 'beginCaptures': + '1': + 'name': 'punctuation.section.metadata.map.begin.clojure' + 'end': '(\\}(?=[\\}\\]\\)\\s]*(?:;|$)))|(\\})' + 'endCaptures': + '1': + 'name': 'punctuation.section.metadata.map.end.trailing.clojure' + '2': + 'name': 'punctuation.section.metadata.map.end.clojure' + 'name': 'meta.metadata.map.clojure' + 'patterns': [ + { + 'include': '$self' + } + ] + } + { + 'begin': '(\\^)' + 'end': '(\\s)' + 'name': 'meta.metadata.simple.clojure' + 'patterns': [ + { + 'include': '#keyword' + } + { + 'include': '$self' + } + ] + } + ] + 'quoted-sexp': + 'begin': '([\'``]\\()' + 'beginCaptures': + '1': + 'name': 'punctuation.section.expression.begin.clojure' + 'end': '(\\))$|(\\)(?=[\\}\\]\\)\\s]*(?:;|$)))|(\\))' + 'endCaptures': + '1': + 'name': 'punctuation.section.expression.end.trailing.clojure' + '2': + 'name': 'punctuation.section.expression.end.trailing.clojure' + '3': + 'name': 'punctuation.section.expression.end.clojure' + 'name': 'meta.quoted-expression.clojure' + 'patterns': [ + { + 'include': '$self' + } + ] + 'regexp': + 'begin': '#"' + 'beginCaptures': + '0': + 'name': 'punctuation.definition.regexp.begin.clojure' + 'end': '"' + 'endCaptures': + '0': + 'name': 'punctuation.definition.regexp.end.clojure' + 'name': 'string.regexp.clojure' + 'patterns': [ + { + 'include': '#regexp_escaped_char' + } + ] + 'regexp_escaped_char': + 'match': '\\\\.' + 'name': 'constant.character.escape.clojure' + 'set': + 'begin': '(\\#\\{)' + 'beginCaptures': + '1': + 'name': 'punctuation.section.set.begin.clojure' + 'end': '(\\}(?=[\\}\\]\\)\\s]*(?:;|$)))|(\\})' + 'endCaptures': + '1': + 'name': 'punctuation.section.set.end.trailing.clojure' + '2': + 'name': 'punctuation.section.set.end.clojure' + 'name': 'meta.set.clojure' + 'patterns': [ + { + 'include': '$self' + } + ] + 'sexp': + 'begin': '(\\()' + 'beginCaptures': + '1': + 'name': 'punctuation.section.expression.begin.clojure' + 'end': '(\\))$|(\\)(?=[\\}\\]\\)\\s]*(?:;|$)))|(\\))' + 'endCaptures': + '1': + 'name': 'punctuation.section.expression.end.trailing.clojure' + '2': + 'name': 'punctuation.section.expression.end.trailing.clojure' + '3': + 'name': 'punctuation.section.expression.end.clojure' + 'name': 'meta.expression.clojure' + 'patterns': [ + { + # ns, declare and everything that starts with def* or namespace/def* + 'begin': '(?<=\\()(ns|declare|def(?!ault)[\\w\\d._:+=>\\<\\!\\?\\*][\\w\\.\\-\\_\\:\\+\\=\\>\\<\\!\\?\\*\\d]*)' + 'name': 'entity.global.clojure' + } + { + 'include': '$self' + } + ] + } + { + 'include': '#keyfn' + } + { + 'include': '#constants' + } + { + 'include': '#vector' + } + { + 'include': '#map' + } + { + 'include': '#set' + } + { + 'include': '#sexp' + } + { + 'match': '(?<=\\()([\\p{L}\\.\\-\\_\\+\\=\\>\\<\\!\\?\\*][\\w\\.\\-\\_\\:\\+\\=\\>\\<\\!\\?\\*\\d]*)/([^"]+?)(?=\\s|\\))' + 'captures': + '1': + 'name': 'entity.name.namespace.clojure' + '2': + 'name': 'entity.name.function.clojure' + } + { + 'match': '(?<=\\()([^"]+?)(?=\\s|\\))' + 'captures': + '1': + 'name': 'entity.name.function.clojure' + 'patterns': [ + { + 'include': '$self' + } + ] + } + { + 'include': '$self' + } + ] + 'shebang-comment': + # NOTE: This must be kept as a begin/end match for language-todo to work + 'begin': '^(#!)' + 'beginCaptures': + '1': + 'name': 'punctuation.definition.comment.shebang.clojure' + 'end': '$' + 'name': 'comment.line.shebang.clojure' + 'string': + 'begin': '(?\\<\\!\\?\\*][\\w\\.\\-\\_\\:\\+\\=\\>\\<\\!\\?\\*\\d]*)/' + 'captures': + '1': + 'name': 'entity.name.namespace.clojure' + } + ] + 'symbol': + 'patterns': [ + { + 'match': '([\\p{L}\\.\\-\\_\\+\\=\\>\\<\\!\\?\\*][\\w\\.\\-\\_\\:\\+\\=\\>\\<\\!\\?\\*\\d]*)' + 'name': 'entity.name.variable.clojure' + } + ] + 'var': + 'match': '(?<=^\\#|[\\s\\(\\[\\{]\\#)\'[\\w\\.\\-\\_\\:\\+\\=\\>\\<\\/\\!\\?\\*]+(?=(\\s|\\)|\\]|\\}))' + 'name': 'meta.var.clojure' + 'vector': + 'begin': '(\\[)' + 'beginCaptures': + '1': + 'name': 'punctuation.section.vector.begin.clojure' + 'end': '(\\](?=[\\}\\]\\)\\s]*(?:;|$)))|(\\])' + 'endCaptures': + '1': + 'name': 'punctuation.section.vector.end.trailing.clojure' + '2': + 'name': 'punctuation.section.vector.end.clojure' + 'name': 'meta.vector.clojure' + 'patterns': [ + { + 'include': '$self' + } + ] + 'prompt': + 'patterns': [ + { + 'match': '^([\\p{L}0-9]+)(꞉)([\\p{L}\\.\\-\\_\\+\\=\\>\\<\\!\\?\\*0-9]+)(꞉>)([ ])' + 'captures': + '1': + 'name': 'keyword.control.prompt.clojure' + '2': + 'name': 'keyword.control.prompt.clojure' + '3': + 'name': 'entity.name.namespace.prompt.clojure' + '4': + 'name': 'keyword.control.prompt.clojure' + } + ] diff --git a/src/calva-fmt/atom-language-clojure/package.json b/src/calva-fmt/atom-language-clojure/package.json new file mode 100644 index 0000000..e89adb1 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/package.json @@ -0,0 +1,28 @@ +{ + "name": "language-clojure", + "version": "0.22.7", + "description": "Clojure language support in Atom", + "engines": { + "atom": "*", + "node": "*" + }, + "homepage": "http://atom.github.io/language-clojure", + "repository": { + "type": "git", + "url": "https://github.com/atom/language-clojure" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/atom/language-clojure/issues" + }, + "scripts": { + "preinstall": "npx npm-force-resolutions" + }, + "devDependencies": { + "coffeelint": "^1.10.1", + "minimist": "^1.2.6" + }, + "resolutions": { + "minimist": "^1.2.5" + } +} diff --git a/src/calva-fmt/atom-language-clojure/settings/language-clojure.cson b/src/calva-fmt/atom-language-clojure/settings/language-clojure.cson new file mode 100644 index 0000000..d0dd718 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/settings/language-clojure.cson @@ -0,0 +1,5 @@ +'.source.clojure': + 'editor': + 'commentStart': '; ' + 'autocomplete': + 'extraWordCharacters': '-' diff --git a/src/calva-fmt/atom-language-clojure/snippets/language-clojure.cson b/src/calva-fmt/atom-language-clojure/snippets/language-clojure.cson new file mode 100644 index 0000000..4b56104 --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/snippets/language-clojure.cson @@ -0,0 +1,111 @@ +'.source.clojure': + 'ns': + 'prefix': 'ns' + 'body': """ + (ns ${1:name} + (:require [${2:libraries}])) + $0 + """ + + 'def': + 'prefix': 'def' + 'body': '(def ${1:symbol} ${2:value})' + + 'defn': + 'prefix': 'defn' + 'body': """ + (defn ${1:name} + [${2:params}] + ${3:body}) + """ + + 'fn': + 'prefix': 'fn' + 'body': """ + (fn [${1:params}] + ${2:body})$0 + """ + + 'let': + 'prefix': 'let' + 'body': """ + (let [${1:bindings}] + ${2:body}) + """ + + 'if': + 'prefix': 'if' + 'body': """ + (if ${1:test} + ${2:then} + ${3:else}) + """ + + 'if-let': + 'prefix': 'ifl' + 'body': """ + (if-let [${1:bindings}] + ${2:then} + ${3:else}) + """ + + 'if-not': + 'prefix': 'ifn' + 'body': """ + (if-not ${1:test} + ${2:then} + ${3:else}) + """ + + 'when': + 'prefix': 'when' + 'body': """ + (when ${1:test} + ${2:body}) + """ + + 'when-let': + 'prefix': 'whenl' + 'body': """ + (when-let [${1:bindings}] + ${2:body}) + """ + + 'when-not': + 'prefix': 'whenn' + 'body': """ + (when-not ${1:test} + ${2:body}) + """ + + 'map': + 'prefix': 'map' + 'body': '(map $1 $2)' + + 'map lambda': + 'prefix': 'mapl' + 'body': '(map #($1) $2)' + + 'condp': + 'prefix': 'condp' + 'body': """ + (condp ${1:pred} ${2:expr} + $0) + """ + + 'try': + 'prefix': 'try' + 'body': """ + (try + $1 + (catch ${2:exception} e + $3)) + """ + + 'prn': + 'prefix': 'prn' + 'body': '(prn $1)' + + 'println': + 'prefix': 'prnl' + 'body': '(println $1)' diff --git a/src/calva-fmt/atom-language-clojure/spec/clojure-spec.coffee b/src/calva-fmt/atom-language-clojure/spec/clojure-spec.coffee new file mode 100644 index 0000000..84f385c --- /dev/null +++ b/src/calva-fmt/atom-language-clojure/spec/clojure-spec.coffee @@ -0,0 +1,471 @@ +describe "Clojure grammar", -> + grammar = null + + beforeEach -> + waitsForPromise -> + atom.packages.activatePackage("language-clojure") + + runs -> + grammar = atom.grammars.grammarForScopeName("source.clojure") + + it "parses the grammar", -> + expect(grammar).toBeDefined() + expect(grammar.scopeName).toBe "source.clojure" + + it "tokenizes semicolon comments", -> + {tokens} = grammar.tokenizeLine "; clojure" + expect(tokens[0]).toEqual value: ";", scopes: ["source.clojure", "comment.line.semicolon.clojure", "punctuation.definition.comment.clojure"] + expect(tokens[1]).toEqual value: " clojure", scopes: ["source.clojure", "comment.line.semicolon.clojure"] + + it "does not tokenize escaped semicolons as comments", -> + {tokens} = grammar.tokenizeLine "\\; clojure" + expect(tokens[0]).toEqual value: "\\; ", scopes: ["source.clojure"] + expect(tokens[1]).toEqual value: "clojure", scopes: ["source.clojure", "entity.name.variable.clojure"] + + it "tokenizes shebang comments", -> + {tokens} = grammar.tokenizeLine "#!/usr/bin/env clojure" + expect(tokens[0]).toEqual value: "#!", scopes: ["source.clojure", "comment.line.shebang.clojure", "punctuation.definition.comment.shebang.clojure"] + expect(tokens[1]).toEqual value: "/usr/bin/env clojure", scopes: ["source.clojure", "comment.line.shebang.clojure"] + + it "tokenizes strings", -> + {tokens} = grammar.tokenizeLine '"foo bar"' + expect(tokens[0]).toEqual value: '"', scopes: ["source.clojure", "string.quoted.double.clojure", "punctuation.definition.string.begin.clojure"] + expect(tokens[1]).toEqual value: 'foo bar', scopes: ["source.clojure", "string.quoted.double.clojure"] + expect(tokens[2]).toEqual value: '"', scopes: ["source.clojure", "string.quoted.double.clojure", "punctuation.definition.string.end.clojure"] + + it "tokenizes character escape sequences", -> + {tokens} = grammar.tokenizeLine '"\\n"' + expect(tokens[0]).toEqual value: '"', scopes: ["source.clojure", "string.quoted.double.clojure", "punctuation.definition.string.begin.clojure"] + expect(tokens[1]).toEqual value: '\\n', scopes: ["source.clojure", "string.quoted.double.clojure", "constant.character.escape.clojure"] + expect(tokens[2]).toEqual value: '"', scopes: ["source.clojure", "string.quoted.double.clojure", "punctuation.definition.string.end.clojure"] + + it "tokenizes regexes", -> + {tokens} = grammar.tokenizeLine '#"foo"' + expect(tokens[0]).toEqual value: '#"', scopes: ["source.clojure", "string.regexp.clojure", "punctuation.definition.regexp.begin.clojure"] + expect(tokens[1]).toEqual value: 'foo', scopes: ["source.clojure", "string.regexp.clojure"] + expect(tokens[2]).toEqual value: '"', scopes: ["source.clojure", "string.regexp.clojure", "punctuation.definition.regexp.end.clojure"] + + it "tokenizes backslash escape character in regexes", -> + {tokens} = grammar.tokenizeLine '#"\\\\" "/"' + expect(tokens[0]).toEqual value: '#"', scopes: ["source.clojure", "string.regexp.clojure", "punctuation.definition.regexp.begin.clojure"] + expect(tokens[1]).toEqual value: "\\\\", scopes: ['source.clojure', 'string.regexp.clojure', 'constant.character.escape.clojure'] + expect(tokens[2]).toEqual value: '"', scopes: ['source.clojure', 'string.regexp.clojure', "punctuation.definition.regexp.end.clojure"] + expect(tokens[4]).toEqual value: '"', scopes: ['source.clojure', 'string.quoted.double.clojure', 'punctuation.definition.string.begin.clojure'] + expect(tokens[5]).toEqual value: "/", scopes: ['source.clojure', 'string.quoted.double.clojure'] + expect(tokens[6]).toEqual value: '"', scopes: ['source.clojure', 'string.quoted.double.clojure', 'punctuation.definition.string.end.clojure'] + + it "tokenizes escaped double quote in regexes", -> + {tokens} = grammar.tokenizeLine '#"\\""' + expect(tokens[0]).toEqual value: '#"', scopes: ["source.clojure", "string.regexp.clojure", "punctuation.definition.regexp.begin.clojure"] + expect(tokens[1]).toEqual value: '\\"', scopes: ['source.clojure', 'string.regexp.clojure', 'constant.character.escape.clojure'] + expect(tokens[2]).toEqual value: '"', scopes: ['source.clojure', 'string.regexp.clojure', "punctuation.definition.regexp.end.clojure"] + + it "tokenizes numerics", -> + numbers = + "constant.numeric.ratio.clojure": ["1/2", "123/456", "+0/2", "-23/1"] + "constant.numeric.arbitrary-radix.clojure": ["2R1011", "16rDEADBEEF", "16rDEADBEEFN", "36rZebra"] + "constant.numeric.hexadecimal.clojure": ["0xDEADBEEF", "0XDEADBEEF", "0xDEADBEEFN", "0x0"] + "constant.numeric.octal.clojure": ["0123", "0123N", "00"] + "constant.numeric.double.clojure": ["123.45", "123.45e6", "123.45E6", "123.456M", "42.", "42.M", "42E+9M", "42E-0", "0M", "+0M", "42.E-23M"] + "constant.numeric.long.clojure": ["123", "12321", "123N", "+123N", "-123", "0"] + "constant.numeric.symbol.clojure": ["##Inf", "##-Inf", "##NaN"] + + for scope, nums of numbers + for num in nums + {tokens} = grammar.tokenizeLine num + expect(tokens[0]).toEqual value: num, scopes: ["source.clojure", scope] + + it "tokenizes booleans", -> + booleans = + "constant.language.boolean.clojure": ["true", "false"] + + for scope, bools of booleans + for bool in bools + {tokens} = grammar.tokenizeLine bool + expect(tokens[0]).toEqual value: bool, scopes: ["source.clojure", scope] + {tokens} = grammar.tokenizeLine " " + bool + expect(tokens[1]).toEqual value: bool, scopes: ["source.clojure", scope] + {tokens} = grammar.tokenizeLine bool + " " + expect(tokens[0]).toEqual value: bool, scopes: ["source.clojure", scope] + {tokens} = grammar.tokenizeLine "," + bool + expect(tokens[1]).toEqual value: bool, scopes: ["source.clojure", scope] + {tokens} = grammar.tokenizeLine bool + "," + expect(tokens[0]).toEqual value: bool, scopes: ["source.clojure", scope] + {tokens} = grammar.tokenizeLine "(not " + bool + ")" + expect(tokens[3]).toEqual value: bool, scopes: ["source.clojure", "meta.expression.clojure", scope] + {tokens} = grammar.tokenizeLine "[" + bool + "]" + expect(tokens[1]).toEqual value: bool, scopes: ["source.clojure", "meta.vector.clojure", scope] + {tokens} = grammar.tokenizeLine "{:a " + bool + "}" + expect(tokens[3]).toEqual value: bool, scopes: ["source.clojure", "meta.map.clojure", scope] + {tokens} = grammar.tokenizeLine bool + "^{:hi 1}[]" + expect(tokens[0]).toEqual value: bool, scopes: ["source.clojure", scope] + + + it "tokenizes nil", -> + {tokens} = grammar.tokenizeLine "nil" + expect(tokens[0]).toEqual value: "nil", scopes: ["source.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine " nil" + expect(tokens[1]).toEqual value: "nil", scopes: ["source.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine "nil " + expect(tokens[0]).toEqual value: "nil", scopes: ["source.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine ",nil" + expect(tokens[1]).toEqual value: "nil", scopes: ["source.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine "nil," + expect(tokens[0]).toEqual value: "nil", scopes: ["source.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine "(conj nil)" + expect(tokens[3]).toEqual value: "nil", scopes: ["source.clojure", "meta.expression.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine "[nil]" + expect(tokens[1]).toEqual value: "nil", scopes: ["source.clojure", "meta.vector.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine "{:a nil}" + expect(tokens[3]).toEqual value: "nil", scopes: ["source.clojure", "meta.map.clojure", "constant.language.nil.clojure"] + {tokens} = grammar.tokenizeLine "nil^{:hi 1}[]" + expect(tokens[0]).toEqual value: "nil", scopes: ["source.clojure", "constant.language.nil.clojure"] + + it "tokenizes keywords", -> + tests = + "meta.expression.clojure": ["(:foo)"] + "meta.map.clojure": ["{:foo}"] + "meta.vector.clojure": ["[:foo]"] + "meta.quoted-expression.clojure": ["'(:foo)", "`(:foo)"] + + for metaScope, lines of tests + for line in lines + {tokens} = grammar.tokenizeLine line + expect(tokens[1]).toEqual value: ":foo", scopes: ["source.clojure", metaScope, "constant.keyword.clojure"] + + {tokens} = grammar.tokenizeLine "(def foo :bar)" + expect(tokens[5]).toEqual value: ":bar", scopes: ["source.clojure", "meta.expression.clojure", "meta.definition.global.clojure", "constant.keyword.clojure"] + + # keywords can start with an uppercase non-ASCII letter + {tokens} = grammar.tokenizeLine "(def foo :Öπ)" + expect(tokens[5]).toEqual value: ":Öπ", scopes: ["source.clojure", "meta.expression.clojure", "meta.definition.global.clojure", "constant.keyword.clojure"] + + it "tokenizes keyfns (keyword control)", -> + keyfns = ["declare", "declare-", "ns", "in-ns", "import", "use", "require", "load", "compile", "def", "defn", "defn-", "defmacro", "defåπç"] + + for keyfn in keyfns + {tokens} = grammar.tokenizeLine "(#{keyfn})" + expect(tokens[1]).toEqual value: keyfn, scopes: ["source.clojure", "meta.expression.clojure", "storage.control.clojure"] + + it "does not tokenize `default...`s as keyfn (keyword control)", -> + {tokens} = grammar.tokenizeLine "(defnormal foo)" + expect(tokens[1].scopes).toContain "storage.control.clojure" + {tokens} = grammar.tokenizeLine "(foo/defnormal foo)" + expect(tokens[1].scopes).toContain "storage.control.clojure" + {tokens} = grammar.tokenizeLine "(normaldef foo)" + expect(tokens[1].scopes).not.toContain "storage.control.clojure" + {tokens} = grammar.tokenizeLine "(default foo)" + expect(tokens[1].scopes).not.toContain "storage.control.clojure" + {tokens} = grammar.tokenizeLine "(defaultfoo ba)" + expect(tokens[1].scopes).not.toContain "storage.control.clojure" + {tokens} = grammar.tokenizeLine "(foo/default foo)" + expect(tokens[1].scopes).not.toContain "storage.control.clojure" + {tokens} = grammar.tokenizeLine "(foo/defaultfoo ba)" + expect(tokens[1].scopes).not.toContain "storage.control.clojure" + + it "tokenizes keyfns (storage control)", -> + keyfns = ["if", "when", "for", "cond", "do", "let", "binding", "loop", "recur", "fn", "throw", "try", "catch", "finally", "case"] + + for keyfn in keyfns + {tokens} = grammar.tokenizeLine "(#{keyfn})" + expect(tokens[1]).toEqual value: keyfn, scopes: ["source.clojure", "meta.expression.clojure", "storage.control.clojure"] + + it "tokenizes global definitions", -> + macros = ["ns", "declare", "def", "defn", "defn-", "defroutes", "compojure/defroutes", "rum.core/defc123-", "some.nested-ns/def-nested->symbol!?*", "def+!.?abc8:<>", "ns/def+!.?abc8:<>", "ns/defåÄÖπç"] + + for macro in macros + {tokens} = grammar.tokenizeLine "(#{macro} foo 'bar)" + expect(tokens[1]).toEqual value: macro, scopes: ["source.clojure", "meta.expression.clojure", "meta.definition.global.clojure", "storage.control.clojure"] + expect(tokens[3]).toEqual value: "foo", scopes: ["source.clojure", "meta.expression.clojure", "meta.definition.global.clojure", "entity.global.clojure"] + + it "tokenizes dynamic variables", -> + mutables = ["*ns*", "*foo-bar*", "*åÄÖπç*"] + + for mutable in mutables + {tokens} = grammar.tokenizeLine mutable + expect(tokens[0]).toEqual value: mutable, scopes: ["source.clojure", "entity.name.variable.dynamic.clojure"] + + it "tokenizes metadata", -> + {tokens} = grammar.tokenizeLine "^Foo" + expect(tokens[0]).toEqual value: "^", scopes: ["source.clojure", "meta.metadata.simple.clojure"] + expect(tokens[1]).toEqual value: "Foo", scopes: ["source.clojure", "meta.metadata.simple.clojure", "entity.name.variable.clojure"] + + # non-ASCII letters + {tokens} = grammar.tokenizeLine "^Öπ" + expect(tokens[0]).toEqual value: "^", scopes: ["source.clojure", "meta.metadata.simple.clojure"] + expect(tokens[1]).toEqual value: "Öπ", scopes: ["source.clojure", "meta.metadata.simple.clojure", "entity.name.variable.clojure"] + + {tokens} = grammar.tokenizeLine "^{:foo true}" + expect(tokens[0]).toEqual value: "^{", scopes: ["source.clojure", "meta.metadata.map.clojure", "punctuation.section.metadata.map.begin.clojure"] + expect(tokens[1]).toEqual value: ":foo", scopes: ["source.clojure", "meta.metadata.map.clojure", "constant.keyword.clojure"] + expect(tokens[2]).toEqual value: " ", scopes: ["source.clojure", "meta.metadata.map.clojure"] + expect(tokens[3]).toEqual value: "true", scopes: ["source.clojure", "meta.metadata.map.clojure", "constant.language.boolean.clojure"] + expect(tokens[4]).toEqual value: "}", scopes: ["source.clojure", "meta.metadata.map.clojure", "punctuation.section.metadata.map.end.trailing.clojure"] + + it "tokenizes functions", -> + expressions = ["(foo)", "(foo 1 10)"] + + for expr in expressions + {tokens} = grammar.tokenizeLine expr + expect(tokens[1]).toEqual value: "foo", scopes: ["source.clojure", "meta.expression.clojure", "entity.name.function.clojure"] + + namespaced_expressions = ["(bar/foo)", "(bar/foo 1 10)"] + + for expr in namespaced_expressions + {tokens} = grammar.tokenizeLine expr + expect(tokens[1]).toEqual value: "bar", scopes: ["source.clojure", "meta.expression.clojure", "entity.name.namespace.clojure"] + expect(tokens[2]).toEqual value: "/", scopes: ["source.clojure", "meta.expression.clojure"] + expect(tokens[3]).toEqual value: "foo", scopes: ["source.clojure", "meta.expression.clojure", "entity.name.function.clojure"] + + #non-ASCII letters + {tokens} = grammar.tokenizeLine "(Öπ 2 20)" + expect(tokens[1]).toEqual value: "Öπ", scopes: ["source.clojure", "meta.expression.clojure", "entity.name.function.clojure"] + + it "tokenizes vars", -> + {tokens} = grammar.tokenizeLine "(func #'foo)" + expect(tokens[2]).toEqual value: " #", scopes: ["source.clojure", "meta.expression.clojure"] + expect(tokens[3]).toEqual value: "'foo", scopes: ["source.clojure", "meta.expression.clojure", "meta.var.clojure"] + + # non-ASCII letters + {tokens} = grammar.tokenizeLine "(func #'Öπ)" + expect(tokens[2]).toEqual value: " #", scopes: ["source.clojure", "meta.expression.clojure"] + expect(tokens[3]).toEqual value: "'Öπ", scopes: ["source.clojure", "meta.expression.clojure", "meta.var.clojure"] + + it "tokenizes symbols", -> + {tokens} = grammar.tokenizeLine "x" + expect(tokens[0]).toEqual value: "x", scopes: ["source.clojure", "entity.name.variable.clojure"] + + # non-ASCII letters + {tokens} = grammar.tokenizeLine "Öπ" + expect(tokens[0]).toEqual value: "Öπ", scopes: ["source.clojure", "entity.name.variable.clojure"] + + # Should not be tokenized as a symbol + {tokens} = grammar.tokenizeLine "1foobar" + expect(tokens[0]).toEqual value: "1", scopes: ["source.clojure", "constant.numeric.long.clojure"] + + it "tokenizes namespaces", -> + {tokens} = grammar.tokenizeLine "foo/bar" + expect(tokens[0]).toEqual value: "foo", scopes: ["source.clojure", "entity.name.namespace.clojure"] + expect(tokens[1]).toEqual value: "/", scopes: ["source.clojure"] + expect(tokens[2]).toEqual value: "bar", scopes: ["source.clojure", "entity.name.variable.clojure"] + + # non-ASCII letters + {tokens} = grammar.tokenizeLine "Öπ/Åä" + expect(tokens[0]).toEqual value: "Öπ", scopes: ["source.clojure", "entity.name.namespace.clojure"] + expect(tokens[1]).toEqual value: "/", scopes: ["source.clojure"] + expect(tokens[2]).toEqual value: "Åä", scopes: ["source.clojure", "entity.name.variable.clojure"] + + testMetaSection = (metaScope, puncScope, startsWith, endsWith) -> + # Entire expression on one line. + {tokens} = grammar.tokenizeLine "#{startsWith}foo, bar#{endsWith}" + + [start, mid..., end] = tokens + + expect(start).toEqual value: startsWith, scopes: ["source.clojure", "meta.#{metaScope}.clojure", "punctuation.section.#{puncScope}.begin.clojure"] + expect(end).toEqual value: endsWith, scopes: ["source.clojure", "meta.#{metaScope}.clojure", "punctuation.section.#{puncScope}.end.trailing.clojure"] + + for token in mid + expect(token.scopes.slice(0, 2)).toEqual ["source.clojure", "meta.#{metaScope}.clojure"] + + # Expression broken over multiple lines. + tokens = grammar.tokenizeLines("#{startsWith}foo\n bar#{endsWith}") + + [start, mid..., after] = tokens[0] + + expect(start).toEqual value: startsWith, scopes: ["source.clojure", "meta.#{metaScope}.clojure", "punctuation.section.#{puncScope}.begin.clojure"] + + for token in mid + expect(token.scopes.slice(0, 2)).toEqual ["source.clojure", "meta.#{metaScope}.clojure"] + + [mid..., end] = tokens[1] + + expect(end).toEqual value: endsWith, scopes: ["source.clojure", "meta.#{metaScope}.clojure", "punctuation.section.#{puncScope}.end.trailing.clojure"] + + for token in mid + expect(token.scopes.slice(0, 2)).toEqual ["source.clojure", "meta.#{metaScope}.clojure"] + + it "tokenizes expressions", -> + testMetaSection "expression", "expression", "(", ")" + + it "tokenizes quoted expressions", -> + testMetaSection "quoted-expression", "expression", "'(", ")" + testMetaSection "quoted-expression", "expression", "`(", ")" + + it "tokenizes vectors", -> + testMetaSection "vector", "vector", "[", "]" + + it "tokenizes maps", -> + testMetaSection "map", "map", "{", "}" + + it "tokenizes sets", -> + testMetaSection "set", "set", "\#{", "}" + + it "tokenizes functions in nested sexp", -> + {tokens} = grammar.tokenizeLine "((foo bar) baz)" + expect(tokens[0]).toEqual value: "(", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.begin.clojure"] + expect(tokens[1]).toEqual value: "(", scopes: ["source.clojure", "meta.expression.clojure", "meta.expression.clojure", "punctuation.section.expression.begin.clojure"] + expect(tokens[2]).toEqual value: "foo", scopes: ["source.clojure", "meta.expression.clojure", "meta.expression.clojure", "entity.name.function.clojure"] + expect(tokens[3]).toEqual value: " ", scopes: ["source.clojure", "meta.expression.clojure", "meta.expression.clojure"] + expect(tokens[4]).toEqual value: "bar", scopes: ["source.clojure", "meta.expression.clojure", "meta.expression.clojure", "entity.name.variable.clojure"] + expect(tokens[5]).toEqual value: ")", scopes: ["source.clojure", "meta.expression.clojure", "meta.expression.clojure", "punctuation.section.expression.end.clojure"] + expect(tokens[6]).toEqual value: " ", scopes: ["source.clojure", "meta.expression.clojure"] + expect(tokens[7]).toEqual value: "baz", scopes: ["source.clojure", "meta.expression.clojure", "entity.name.variable.clojure"] + expect(tokens[8]).toEqual value: ")", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.end.trailing.clojure"] + + it "tokenizes maps used as functions", -> + {tokens} = grammar.tokenizeLine "({:foo bar} :foo)" + expect(tokens[0]).toEqual value: "(", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.begin.clojure"] + expect(tokens[1]).toEqual value: "{", scopes: ["source.clojure", "meta.expression.clojure", "meta.map.clojure", "punctuation.section.map.begin.clojure"] + expect(tokens[2]).toEqual value: ":foo", scopes: ["source.clojure", "meta.expression.clojure", "meta.map.clojure", "constant.keyword.clojure"] + expect(tokens[3]).toEqual value: " ", scopes: ["source.clojure", "meta.expression.clojure", "meta.map.clojure"] + expect(tokens[4]).toEqual value: "bar", scopes: ["source.clojure", "meta.expression.clojure", "meta.map.clojure", "entity.name.variable.clojure"] + expect(tokens[5]).toEqual value: "}", scopes: ["source.clojure", "meta.expression.clojure", "meta.map.clojure", "punctuation.section.map.end.clojure"] + expect(tokens[6]).toEqual value: " ", scopes: ["source.clojure", "meta.expression.clojure"] + expect(tokens[7]).toEqual value: ":foo", scopes: ["source.clojure", "meta.expression.clojure", "constant.keyword.clojure"] + expect(tokens[8]).toEqual value: ")", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.end.trailing.clojure"] + + it "tokenizes sets used in functions", -> + {tokens} = grammar.tokenizeLine "(\#{:foo :bar})" + expect(tokens[0]).toEqual value: "(", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.begin.clojure"] + expect(tokens[1]).toEqual value: "\#{", scopes: ["source.clojure", "meta.expression.clojure", "meta.set.clojure", "punctuation.section.set.begin.clojure"] + expect(tokens[2]).toEqual value: ":foo", scopes: ["source.clojure", "meta.expression.clojure", "meta.set.clojure", "constant.keyword.clojure"] + expect(tokens[3]).toEqual value: " ", scopes: ["source.clojure", "meta.expression.clojure", "meta.set.clojure"] + expect(tokens[4]).toEqual value: ":bar", scopes: ["source.clojure", "meta.expression.clojure", "meta.set.clojure", "constant.keyword.clojure"] + expect(tokens[5]).toEqual value: "}", scopes: ["source.clojure", "meta.expression.clojure", "meta.set.clojure", "punctuation.section.set.end.trailing.clojure"] + expect(tokens[6]).toEqual value: ")", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.end.trailing.clojure"] + + it "tokenize strings (wrongly) used as functions", -> + {tokens} = grammar.tokenizeLine "(\"foo)\")" + expect(tokens[0]).toEqual value: "(", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.begin.clojure"] + expect(tokens[1]).toEqual value: "\"", scopes: ["source.clojure", "meta.expression.clojure", "string.quoted.double.clojure", "punctuation.definition.string.begin.clojure"] + expect(tokens[2]).toEqual value: "foo)", scopes: ["source.clojure", "meta.expression.clojure", "string.quoted.double.clojure"] + expect(tokens[3]).toEqual value: "\"", scopes: ["source.clojure", "meta.expression.clojure", "string.quoted.double.clojure", "punctuation.definition.string.end.clojure"] + expect(tokens[4]).toEqual value: ")", scopes: ["source.clojure", "meta.expression.clojure", "punctuation.section.expression.end.trailing.clojure"] + + describe "replPrompt", -> + it "tokenizes repl prompt", -> + {tokens} = grammar.tokenizeLine "foo꞉bar.baz-2꞉> " + expect(tokens[0]).toEqual value: "foo", scopes: ["source.clojure", "keyword.control.prompt.clojure"] + expect(tokens[1]).toEqual value: "꞉", scopes: ["source.clojure", "keyword.control.prompt.clojure"] + expect(tokens[2]).toEqual value: "bar.baz-2", scopes: ["source.clojure", "entity.name.namespace.prompt.clojure"] + expect(tokens[3]).toEqual value: "꞉>", scopes: ["source.clojure", "keyword.control.prompt.clojure"] + it "does not tokenize repl prompt when prepended with anything", -> + {tokens} = grammar.tokenizeLine " foo꞉bar.baz-2꞉> " + expect(tokens[0]).toEqual value: " ", scopes: ["source.clojure"] + expect(tokens[1]).toEqual value: "foo", scopes: ["source.clojure", "entity.name.variable.clojure"] + it "does not tokenize repl prompt when not followed by hard space", -> + {tokens} = grammar.tokenizeLine "foo꞉bar.baz-2꞉>" + expect(tokens[0]).toEqual value: "foo", scopes: ["source.clojure", "entity.name.variable.clojure"] + + describe "firstLineMatch", -> + it "recognises interpreter directives", -> + valid = """ + #!/usr/sbin/boot foo + #!/usr/bin/boot foo=bar/ + #!/usr/sbin/boot + #!/usr/sbin/boot foo bar baz + #!/usr/bin/boot perl + #!/usr/bin/boot bin/perl + #!/usr/bin/boot + #!/bin/boot + #!/usr/bin/boot --script=usr/bin + #! /usr/bin/env A=003 B=149 C=150 D=xzd E=base64 F=tar G=gz H=head I=tail boot + #!\t/usr/bin/env --foo=bar boot --quu=quux + #! /usr/bin/boot + #!/usr/bin/env boot + """ + for line in valid.split /\n/ + expect(grammar.firstLineRegex.scanner.findNextMatchSync(line)).not.toBeNull() + + invalid = """ + \x20#!/usr/sbin/boot + \t#!/usr/sbin/boot + #!/usr/bin/env-boot/node-env/ + #!/usr/bin/das-boot + #! /usr/binboot + #!\t/usr/bin/env --boot=bar + """ + for line in invalid.split /\n/ + expect(grammar.firstLineRegex.scanner.findNextMatchSync(line)).toBeNull() + + it "recognises Emacs modelines", -> + valid = """ + #-*- Clojure -*- + #-*- mode: ClojureScript -*- + /* -*-clojureScript-*- */ + // -*- Clojure -*- + /* -*- mode:Clojure -*- */ + // -*- font:bar;mode:Clojure -*- + // -*- font:bar;mode:Clojure;foo:bar; -*- + // -*-font:mode;mode:Clojure-*- + // -*- foo:bar mode: clojureSCRIPT bar:baz -*- + " -*-foo:bar;mode:clojure;bar:foo-*- "; + " -*-font-mode:foo;mode:clojure;foo-bar:quux-*-" + "-*-font:x;foo:bar; mode : clojure; bar:foo;foooooo:baaaaar;fo:ba;-*-"; + "-*- font:x;foo : bar ; mode : ClojureScript ; bar : foo ; foooooo:baaaaar;fo:ba-*-"; + """ + for line in valid.split /\n/ + expect(grammar.firstLineRegex.scanner.findNextMatchSync(line)).not.toBeNull() + + invalid = """ + /* --*clojure-*- */ + /* -*-- clojure -*- + /* -*- -- Clojure -*- + /* -*- Clojure -;- -*- + // -*- iClojure -*- + // -*- Clojure; -*- + // -*- clojure-door -*- + /* -*- model:clojure -*- + /* -*- indent-mode:clojure -*- + // -*- font:mode;Clojure -*- + // -*- mode: -*- Clojure + // -*- mode: das-clojure -*- + // -*-font:mode;mode:clojure--*- + """ + for line in invalid.split /\n/ + expect(grammar.firstLineRegex.scanner.findNextMatchSync(line)).toBeNull() + + it "recognises Vim modelines", -> + valid = """ + vim: se filetype=clojure: + # vim: se ft=clojure: + # vim: set ft=Clojure: + # vim: set filetype=Clojure: + # vim: ft=Clojure + # vim: syntax=Clojure + # vim: se syntax=Clojure: + # ex: syntax=Clojure + # vim:ft=clojure + # vim600: ft=clojure + # vim>600: set ft=clojure: + # vi:noai:sw=3 ts=6 ft=clojure + # vi::::::::::noai:::::::::::: ft=clojure + # vim:ts=4:sts=4:sw=4:noexpandtab:ft=clojure + # vi:: noai : : : : sw =3 ts =6 ft =clojure + # vim: ts=4: pi sts=4: ft=clojure: noexpandtab: sw=4: + # vim: ts=4 sts=4: ft=clojure noexpandtab: + # vim:noexpandtab sts=4 ft=clojure ts=4 + # vim:noexpandtab:ft=clojure + # vim:ts=4:sts=4 ft=clojure:noexpandtab:\x20 + # vim:noexpandtab titlestring=hi\|there\\\\ ft=clojure ts=4 + """ + for line in valid.split /\n/ + expect(grammar.firstLineRegex.scanner.findNextMatchSync(line)).not.toBeNull() + + invalid = """ + ex: se filetype=clojure: + _vi: se filetype=clojure: + vi: se filetype=clojure + # vim set ft=klojure + # vim: soft=clojure + # vim: clean-syntax=clojure: + # vim set ft=clojure: + # vim: setft=clojure: + # vim: se ft=clojure backupdir=tmp + # vim: set ft=clojure set cmdheight=1 + # vim:noexpandtab sts:4 ft:clojure ts:4 + # vim:noexpandtab titlestring=hi\\|there\\ ft=clojure ts=4 + # vim:noexpandtab titlestring=hi\\|there\\\\\\ ft=clojure ts=4 + """ + for line in invalid.split /\n/ + expect(grammar.firstLineRegex.scanner.findNextMatchSync(line)).toBeNull() diff --git a/src/calva-fmt/src/config.ts b/src/calva-fmt/src/config.ts new file mode 100644 index 0000000..e6846d3 --- /dev/null +++ b/src/calva-fmt/src/config.ts @@ -0,0 +1,68 @@ +import * as vscode from 'vscode'; +import * as filesCache from '../../files-cache'; +import * as cljsLib from '../../../out/cljs-lib/cljs-lib.js'; +// import * as lsp from '../../lsp/main'; +const defaultCljfmtContent = + '\ +{:remove-surrounding-whitespace? true\n\ + :remove-trailing-whitespace? true\n\ + :remove-consecutive-blank-lines? false\n\ + :insert-missing-whitespace? true\n\ + :align-associative? false}'; + +const LSP_CONFIG_KEY = 'CLOJURE-LSP'; +let lspFormatConfig: string | undefined; + +function configuration(workspaceConfig: vscode.WorkspaceConfiguration, cljfmt: string) { + return { + 'format-as-you-type': !!workspaceConfig.get('formatAsYouType'), + 'keep-comment-forms-trail-paren-on-own-line?': !!workspaceConfig.get( + 'keepCommentTrailParenOnOwnLine' + ), + 'cljfmt-options-string': cljfmt, + 'cljfmt-options': cljsLib.cljfmtOptionsFromString(cljfmt), + }; +} + +async function readConfiguration(): Promise<{ + 'format-as-you-type': boolean; + 'keep-comment-forms-trail-paren-on-own-line?': boolean; + 'cljfmt-options-string': string; + 'cljfmt-options': object; +}> { + const workspaceConfig = vscode.workspace.getConfiguration('hy.calva.fmt'); + const configPath: string | undefined = workspaceConfig.get('configPath'); + + // if (configPath === LSP_CONFIG_KEY) { + // lspFormatConfig = await lsp.getCljFmtConfig(); + // } + if (configPath === LSP_CONFIG_KEY && !lspFormatConfig) { + void vscode.window.showErrorMessage( + 'Fetching formatting settings from clojure-lsp failed. Check that you are running a version of clojure-lsp that provides "cljfmt-raw" in serverInfo.', + 'Roger that' + ); + } + const cljfmtContent: string | undefined = + configPath === LSP_CONFIG_KEY + ? lspFormatConfig + ? lspFormatConfig + : defaultCljfmtContent + : filesCache.content(configPath); + const config = configuration( + workspaceConfig, + cljfmtContent ? cljfmtContent : defaultCljfmtContent + ); + if (!config['cljfmt-options']['error']) { + return config; + } else { + void vscode.window.showErrorMessage( + `Error parsing ${configPath}: ${config['cljfmt-options']['error']}\n\nUsing default formatting configuration.` + ); + return configuration(workspaceConfig, defaultCljfmtContent); + } +} + +export async function getConfig() { + const config = await readConfiguration(); + return config; +} diff --git a/src/calva-fmt/src/extension.ts b/src/calva-fmt/src/extension.ts new file mode 100644 index 0000000..4c87e06 --- /dev/null +++ b/src/calva-fmt/src/extension.ts @@ -0,0 +1,98 @@ +import * as vscode from 'vscode'; +import { FormatOnTypeEditProvider } from './providers/ontype_formatter'; +import { RangeEditProvider } from './providers/range_formatter'; +import * as formatter from './format'; +import * as inferer from './infer'; +import * as docmirror from '../../doc-mirror/index'; +import * as config from './config'; +import * as hyConfig from '../../config'; + +function getLanguageConfiguration(autoIndentOn: boolean): vscode.LanguageConfiguration { + return { + onEnterRules: + autoIndentOn && hyConfig.getConfig().format + ? [ + // When Calva is the formatter disable all vscode default indentation + // (By outdenting a lot, which is the only way I have found that works) + // TODO: Make it actually consider whether Calva is the formatter or not + { + beforeText: /.*/, + action: { + indentAction: vscode.IndentAction.Outdent, + removeText: Number.MAX_VALUE, + }, + }, + ] + : [], + }; +} + +export async function activate(context: vscode.ExtensionContext) { + docmirror.activate(); + vscode.languages.setLanguageConfiguration( + 'hy', + getLanguageConfiguration(await config.getConfig()['format-as-you-type']) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand( + 'hy.calva-fmt.formatCurrentForm', + formatter.formatPositionCommand + ) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand( + 'hy.calva-fmt.alignCurrentForm', + formatter.alignPositionCommand + ) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand( + 'hy.calva-fmt.trimCurrentFormWhiteSpace', + formatter.trimWhiteSpacePositionCommand + ) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand( + 'hy.calva-fmt.inferParens', + inferer.inferParensCommand) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand( + 'hy.calva-fmt.tabIndent', + (e) => { + inferer.indentCommand(e, ' ', true); + } + ) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand('hy.calva-fmt.tabDedent', (e) => { + inferer.indentCommand(e, ' ', false); + }) + ); + context.subscriptions.push( + vscode.languages.registerOnTypeFormattingEditProvider( + hyConfig.documentSelector, + new FormatOnTypeEditProvider(), + '\r', + '\n', + ')', + ']', + '}' + ) + ); + context.subscriptions.push( + vscode.languages.registerDocumentRangeFormattingEditProvider( + hyConfig.documentSelector, + new RangeEditProvider() + ) + ); + vscode.window.onDidChangeActiveTextEditor(inferer.updateState); + vscode.workspace.onDidChangeConfiguration(async (e) => { + if (e.affectsConfiguration('hy.calva.fmt.formatAsYouType')) { + vscode.languages.setLanguageConfiguration( + 'hy', + getLanguageConfiguration(await config.getConfig()['format-as-you-type']) + ); + } + }); +} diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts new file mode 100644 index 0000000..b6b6df0 --- /dev/null +++ b/src/calva-fmt/src/format.ts @@ -0,0 +1,257 @@ +import * as vscode from 'vscode'; +import * as config from './config'; +import * as path from 'path'; +// import * as outputWindow from '../../results-output/results-doc'; +import { + getIndent, + getDocumentOffset, + MirroredDocument, + getDocument, +} from '../../doc-mirror/index'; +import { + formatTextAtRange, + formatTextAtIdx, + formatTextAtIdxOnType, + formatText, + cljify, + jsify, +} from '../../../out/cljs-lib/cljs-lib'; +import * as util from '../../utilities'; +import { isUndefined, cloneDeep } from 'lodash'; + +export async function indentPosition(position: vscode.Position, document: vscode.TextDocument) { + const editor = util.getActiveTextEditor(); + const pos = new vscode.Position(position.line, 0); + const indent = getIndent( + getDocument(document).model.lineInputModel, + getDocumentOffset(document, position), + await config.getConfig() + ); + let delta = document.lineAt(position.line).firstNonWhitespaceCharacterIndex - indent; + if (delta > 0) { + return editor.edit( + (edits) => edits.delete(new vscode.Range(pos, new vscode.Position(pos.line, delta))), + { + undoStopAfter: false, + undoStopBefore: false, + } + ); + } else if (delta < 0) { + let str = ''; + while (delta++ < 0) { + str += ' '; + } + return editor.edit((edits) => edits.insert(pos, str), { + undoStopAfter: false, + undoStopBefore: false, + }); + } +} + +export async function formatRangeEdits( + document: vscode.TextDocument, + range: vscode.Range +): Promise { + const text: string = document.getText(range); + const mirroredDoc: MirroredDocument = getDocument(document); + const startIndex = document.offsetAt(range.start); + const endIndex = document.offsetAt(range.end); + const cursor = mirroredDoc.getTokenCursor(startIndex); + if (!cursor.withinString()) { + const rangeTuple: number[] = [startIndex, endIndex]; + const newText: string | undefined = await _formatRange( + text, + document.getText(), + rangeTuple, + document.eol == 2 ? '\r\n' : '\n' + ); + if (newText) { + return [vscode.TextEdit.replace(range, newText)]; + } + } +} + +export async function formatRange(document: vscode.TextDocument, range: vscode.Range) { + const wsEdit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); + const edits = await formatRangeEdits(document, range); + + if (isUndefined(edits)) { + console.error('formatRangeEdits returned undefined!', cloneDeep({ document, range })); + return false; + } + + wsEdit.set(document.uri, edits); + return vscode.workspace.applyEdit(wsEdit); +} + +export async function formatPositionInfo( + editor: vscode.TextEditor, + onType: boolean = false, + extraConfig = {} +) { + const doc: vscode.TextDocument = editor.document; + const pos: vscode.Position = editor.selection.active; + const index = doc.offsetAt(pos); + const mirroredDoc: MirroredDocument = getDocument(doc); + const cursor = mirroredDoc.getTokenCursor(index); + const formatDepth = extraConfig['format-depth'] ? extraConfig['format-depth'] : 1; + const isComment = cursor.getFunctionName() === 'comment'; + const config = { ...extraConfig, 'comment-form?': isComment }; + let formatRange = cursor.rangeForList(formatDepth); + if (!formatRange) { + formatRange = cursor.rangeForCurrentForm(index); + if (!formatRange || !formatRange.includes(index)) { + return; + } + } + const formatted: { + 'range-text': string; + range: number[]; + 'new-index': number; + } = await _formatIndex( + doc.getText(), + formatRange, + index, + doc.eol == 2 ? '\r\n' : '\n', + onType, + config + ); + const range: vscode.Range = new vscode.Range( + doc.positionAt(formatted.range[0]), + doc.positionAt(formatted.range[1]) + ); + const newIndex: number = doc.offsetAt(range.start) + formatted['new-index']; + const previousText: string = doc.getText(range); + return { + formattedText: formatted['range-text'], + range: range, + previousText: previousText, + previousIndex: index, + newIndex: newIndex, + }; +} + +// From results-doc.ts +const RESULTS_DOC_NAME = "thing"; + +export function isResultsDoc(doc?: vscode.TextDocument): boolean { + return !!doc && path.basename(doc.fileName) === RESULTS_DOC_NAME; +} + +// End from results-doc.ts + +export async function formatPosition( + editor: vscode.TextEditor, + onType: boolean = false, + extraConfig = {} +): Promise { + // console.log("calva-fmt/src/format.ts/formatPosition called") + const doc: vscode.TextDocument = editor.document, + formattedInfo = await formatPositionInfo(editor, onType, extraConfig); + if (formattedInfo && formattedInfo.previousText != formattedInfo.formattedText) { + return editor + .edit( + (textEditorEdit) => { + textEditorEdit.replace(formattedInfo.range, formattedInfo.formattedText); + }, + { undoStopAfter: false, undoStopBefore: false } + ) + .then((onFulfilled: boolean) => { + editor.selection = new vscode.Selection( + doc.positionAt(formattedInfo.newIndex), + doc.positionAt(formattedInfo.newIndex) + ); + return onFulfilled; + }); + } + if (formattedInfo) { + return new Promise((resolve, _reject) => { + if (formattedInfo.newIndex != formattedInfo.previousIndex) { + editor.selection = new vscode.Selection( + doc.positionAt(formattedInfo.newIndex), + doc.positionAt(formattedInfo.newIndex) + ); + } + resolve(true); + }); + } + if (!onType && !isResultsDoc(doc)) { + return formatRange( + doc, + new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)) + ); + } + return new Promise((resolve, _reject) => { + resolve(true); + }); +} + +export function formatPositionCommand(editor: vscode.TextEditor) { + void formatPosition(editor); +} + +export function alignPositionCommand(editor: vscode.TextEditor) { + void formatPosition(editor, true, { 'align-associative?': true }); +} + +export function trimWhiteSpacePositionCommand(editor: vscode.TextEditor) { + void formatPosition(editor, false, { 'remove-multiple-non-indenting-spaces?': true }); +} + +export async function formatCode(code: string, eol: number) { + const d = { + 'range-text': code, + eol: eol == 2 ? '\r\n' : '\n', + config: await config.getConfig(), + }; + const result = jsify(formatText(d)); + if (!result['error']) { + return result['range-text']; + } else { + console.error('Error in `formatCode`:', result['error']); + return code; + } +} + +async function _formatIndex( + allText: string, + range: [number, number], + index: number, + eol: string, + onType: boolean = false, + extraConfig = {} +): Promise<{ 'range-text': string; range: number[]; 'new-index': number }> { + const d = { + 'all-text': allText, + idx: index, + eol: eol, + range: range, + config: { ...(await config.getConfig()), ...extraConfig }, + }; + const result = jsify(onType ? formatTextAtIdxOnType(d) : formatTextAtIdx(d)); + if (!result['error']) { + return result; + } else { + console.error('Error in `_formatIndex`:', result['error']); + throw result['error']; + } +} + +async function _formatRange( + rangeText: string, + allText: string, + range: number[], + eol: string +): Promise { + const d = { + 'range-text': rangeText, + 'all-text': allText, + range: range, + eol: eol, + config: await config.getConfig(), + }; + const result = jsify(formatTextAtRange(d)); + if (!result['error']) { + return result['range-text']; + } +} diff --git a/src/calva-fmt/src/infer.ts b/src/calva-fmt/src/infer.ts new file mode 100644 index 0000000..aa70bdc --- /dev/null +++ b/src/calva-fmt/src/infer.ts @@ -0,0 +1,137 @@ +import * as vscode from 'vscode'; +import { inferParens, inferIndents } from '../../../out/cljs-lib/cljs-lib'; +import { isUndefined, cloneDeep } from 'lodash'; + +interface CFEdit { + edit: string; + start: { line: number; character: number }; + end: { line: number; character: number }; + text?: string; +} + +interface CFError { + message: string; +} + +interface ResultOptions { + success: boolean; + edits?: [CFEdit]; + line?: number; + character?: number; + error?: CFError; + 'error-msg'?: string; +} + +export function inferParensCommand(editor: vscode.TextEditor) { + // console.log("calva-fmt/src/infer.ts/inferParensCommand called"); + const position: vscode.Position = editor.selection.active, + document = editor.document, + currentText = document.getText(), + r: ResultOptions = inferParens({ + text: currentText, + line: position.line, + character: position.character, + 'previous-line': position.line, + 'previous-character': position.character, + }); + applyResults(r, editor); +} + +export function indentCommand(editor: vscode.TextEditor, spacing: string, forward: boolean = true) { + const prevPosition: vscode.Position = editor.selection.active, + document = editor.document; + let deletedText = '', + doEdit = true; + + void editor + .edit( + (editBuilder) => { + if (forward) { + editBuilder.insert( + new vscode.Position(prevPosition.line, prevPosition.character), + spacing + ); + } else { + const startOfLine = new vscode.Position(prevPosition.line, 0), + headRange = new vscode.Range(startOfLine, prevPosition), + headText = document.getText(headRange), + xOfFirstLeadingSpace = headText.search(/ *$/), + leadingSpaces = + xOfFirstLeadingSpace >= 0 ? prevPosition.character - xOfFirstLeadingSpace : 0; + if (leadingSpaces > 0) { + const spacingSize = Math.max(spacing.length, 1), + deleteRange = new vscode.Range(prevPosition.translate(0, -spacingSize), prevPosition); + deletedText = document.getText(deleteRange); + editBuilder.delete(deleteRange); + } else { + doEdit = false; + } + } + }, + { undoStopAfter: false, undoStopBefore: false } + ) + .then((_onFulfilled: boolean) => { + if (doEdit) { + const position: vscode.Position = editor.selection.active, + currentText = document.getText(), + r: ResultOptions = inferIndents({ + text: currentText, + line: position.line, + character: position.character, + 'previous-line': prevPosition.line, + 'previous-character': prevPosition.character, + changes: [ + { + line: forward ? prevPosition.line : position.line, + character: forward ? prevPosition.character : position.character, + 'old-text': forward ? '' : deletedText, + 'new-text': forward ? spacing : '', + }, + ], + }); + applyResults(r, editor); + } + }); +} + +function applyResults(r: ResultOptions, editor: vscode.TextEditor) { + if (r.success) { + void editor + .edit( + (editBuilder) => { + if (isUndefined(r.edits)) { + console.error('Edits were undefined!', cloneDeep({ editBuilder, r, editor })); + return; + } + r.edits.forEach((edit: CFEdit) => { + const start = new vscode.Position(edit.start.line, edit.start.character), + end = new vscode.Position(edit.end.line, edit.end.character); + if (isUndefined(edit.text)) { + console.error( + 'edit.text was undefined!', + cloneDeep({ edit, editBuilder, r, editor }) + ); + return; + } + editBuilder.replace(new vscode.Range(start, end), edit.text); + }); + }, + { undoStopAfter: true, undoStopBefore: false } + ) + .then((_onFulfilled: boolean) => { + // these will never be undefined in practice: + // https://github.com/BetterThanTomorrow/calva/blob/5d23da5704989e000b1f860fc09f5935d7bac3f5/src/cljs-lib/src/calva/fmt/editor.cljs#L5-L21 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion + const newPosition = new vscode.Position(r.line!, r.character!); + editor.selections = [new vscode.Selection(newPosition, newPosition)]; + }); + } else { + void vscode.window.showErrorMessage( + 'Calva Formatter Error: ' + (r.error ? r.error.message : r['error-msg']) + ); + } +} + +export function updateState(editor: vscode.TextEditor) { + // do nothing +} diff --git a/src/calva-fmt/src/providers/ontype_formatter.ts b/src/calva-fmt/src/providers/ontype_formatter.ts new file mode 100644 index 0000000..547e5f5 --- /dev/null +++ b/src/calva-fmt/src/providers/ontype_formatter.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import * as formatter from '../format'; +import * as docMirror from '../../../doc-mirror/index'; +import { EditableDocument } from '../../../cursor-doc/model'; +import * as paredit from '../../../cursor-doc/paredit'; +import { getConfig } from '../../../config'; +import * as util from '../../../utilities'; + +export class FormatOnTypeEditProvider implements vscode.OnTypeFormattingEditProvider { + async provideOnTypeFormattingEdits( + document: vscode.TextDocument, + _position: vscode.Position, + ch: string, + _options + ): Promise { + let keyMap = vscode.workspace.getConfiguration().get('hy.paredit.defaultKeyMap'); + keyMap = String(keyMap).trim().toLowerCase(); + if ([')', ']', '}'].includes(ch)) { + if (keyMap === 'strict' && getConfig().strictPreventUnmatchedClosingBracket) { + const mDoc: EditableDocument = docMirror.getDocument(document); + const tokenCursor = mDoc.getTokenCursor(); + if (tokenCursor.withinComment()) { + return undefined; + } + // TODO: We should make a function in/for the MirrorDoc that can return + // edits instead of performing them. It is not awesome to perform edits + // here, since we are expected to return them. + await paredit.backspace(mDoc); + await paredit.close(mDoc, ch); + } else { + return undefined; + } + } + const editor = util.getActiveTextEditor(); + + const pos = editor.selection.active; + if (vscode.workspace.getConfiguration('hy.calva.fmt').get('formatAsYouType')) { + if (vscode.workspace.getConfiguration('hy.calva.fmt').get('newIndentEngine')) { + void formatter.indentPosition(pos, document); + } else { + try { + void formatter.formatPosition(editor, true); + } catch (e) { + void formatter.indentPosition(pos, document); + } + } + } + + return undefined; + } +} diff --git a/src/calva-fmt/src/providers/range_formatter.ts b/src/calva-fmt/src/providers/range_formatter.ts new file mode 100644 index 0000000..d8ffec7 --- /dev/null +++ b/src/calva-fmt/src/providers/range_formatter.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; +import * as formatter from '../format'; + +export class RangeEditProvider implements vscode.DocumentRangeFormattingEditProvider { + provideDocumentRangeFormattingEdits( + document: vscode.TextDocument, + range: vscode.Range, + _options, + _token + ) { + return formatter.formatRangeEdits(document, range); + } +} diff --git a/src/calva-fmt/src/state.ts b/src/calva-fmt/src/state.ts new file mode 100644 index 0000000..8ea29d8 --- /dev/null +++ b/src/calva-fmt/src/state.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import * as Immutable from 'immutable'; +import * as ImmutableCursor from 'immutable-cursor'; + +const mode = { + language: 'clojure', + //scheme: 'file' +}; + +let data; +const initialData = { + documents: {}, +}; + +reset(); + +const cursor = ImmutableCursor.from(data, [], (nextState) => { + data = Immutable.fromJS(nextState); +}); + +function deref() { + return data; +} + +function reset() { + data = Immutable.fromJS(initialData); +} + +function config() { + const configOptions = vscode.workspace.getConfiguration('hy.calva.fmt'); + return { + parinferOnSelectionChange: configOptions.get('inferParensOnCursorMove'), + }; +} + +export { cursor, mode, deref, reset, config }; diff --git a/src/calva-fmt/update-grammar.js b/src/calva-fmt/update-grammar.js new file mode 100644 index 0000000..ca4976a --- /dev/null +++ b/src/calva-fmt/update-grammar.js @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + *--------------------------------------------------------------------------------------------*/ + +/** + * MIT License + * + * Copyright (c) 2015 - present Microsoft Corporation + * + * All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +'use strict'; + +var path = require('path'); +var fs = require('fs'); +var cson = require('cson-parser'); + +exports.update = function (contentPath, dest) { + console.log('Reading from ' + contentPath); + fs.readFile(contentPath, (_err, content) => { + var grammar = cson.parse(content); + const result = { + information_for_contributors: ['This file is generated from ' + contentPath], + }; + + const keys = ['name', 'scopeName', 'comment', 'injections', 'patterns', 'repository']; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(grammar, key)) { + result[key] = grammar[key]; + } + } + + try { + fs.writeFileSync(dest, JSON.stringify(result, null, '\t').replace(/\n/g, '\r\n')); + console.log('Updated ' + path.basename(dest)); + } catch (e) { + console.error(e); + process.exit(1); + } + }); +}; + +if (path.basename(process.argv[1]) === 'update-grammar.js') { + exports.update(process.argv[2], process.argv[3]); +} diff --git a/src/cljs-lib/src/calva/dartclojure.cljs b/src/cljs-lib/src/calva/dartclojure.cljs new file mode 100644 index 0000000..a55269a --- /dev/null +++ b/src/cljs-lib/src/calva/dartclojure.cljs @@ -0,0 +1,34 @@ +(ns calva.dartclojure + (:require [dumch.convert :as dart->clj])) + +(defn convert [dart-string] + (try + {:result + (dart->clj/convert dart-string :string :sexpr)} + (catch :default e + {:error {:message "Error parsing Dart code" + :exception {:name (.-name e) + :message (.-message e)}}}))) + +(defn convert-bridge [dart-string] + (convert dart-string)) + +(comment + (convert + " + (context, index) { + if (index == 0) { + return const Padding( + padding: EdgeInsets.only(left: 15, top: 16, bottom: 8), + child: Text( + 'You might also like:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ); + } + return const SongPlaceholderTile(); + }; + ")) \ No newline at end of file diff --git a/src/cljs-lib/src/calva/fmt/editor.cljs b/src/cljs-lib/src/calva/fmt/editor.cljs new file mode 100644 index 0000000..2342c4f --- /dev/null +++ b/src/cljs-lib/src/calva/fmt/editor.cljs @@ -0,0 +1,37 @@ +(ns calva.fmt.editor + (:require [calva.fmt.util :as util])) + + +(defn raplacement-edits-for-diffing-lines + "Returns a list of replacement edits to apply to `old-text` to get `new-text`. + Edits will be in the form `[:replace [range] text]`, + where `range` is in the form `[[start-line start-char] [end-line end-char]]`. + NB: The two versions need to have the same amount of lines." + [old-text new-text] + (let [old-lines (util/split-into-lines old-text) + new-lines (util/split-into-lines new-text)] + (->> (map vector (range) old-lines new-lines) + (remove (fn [[line o n]] (= o n))) + (mapv (fn [[line o n]] + {:edit "replace" + :start {:line line + :character 0} + :end {:line line + :character (count o)} + :text n}))))) + + +(comment + (raplacement-edits-for-diffing-lines "foo\nfooo\nbar\nbar" + "foo\nbar\nbaz\nbar") + (->> (map vector + [:foo :foo :foo] + [:foo :bar :foo] + (range)) + (remove (fn [[o n i]] + (= o n)))) + (filter some? + (map (fn [o n line] (when-not (= o n) [o n line])) + [:foo :foo :foo] + [:foo :bar :foo] + (range)))) \ No newline at end of file diff --git a/src/cljs-lib/src/calva/fmt/formatter.cljs b/src/cljs-lib/src/calva/fmt/formatter.cljs new file mode 100644 index 0000000..56aa015 --- /dev/null +++ b/src/cljs-lib/src/calva/fmt/formatter.cljs @@ -0,0 +1,339 @@ +(ns calva.fmt.formatter + (:require [pez-cljfmt.core :as pez-cljfmt] + [cljfmt.core :as cljfmt] + #_[zprint.core :refer [zprint-str]] + [calva.js-utils :refer [jsify cljify]] + [calva.fmt.util :as util] + [calva.parse :refer [parse-clj-edn]] + [clojure.string])) + +(defn- merge-default-indents + "Merges onto default-indents. + The :replace metadata hint allows to replace defaults." + [indents] + (if (:replace (meta indents)) + indents + (merge cljfmt/default-indents indents))) + +(def ^:private default-fmt + {:remove-surrounding-whitespace? true + :remove-trailing-whitespace? true + :remove-consecutive-blank-lines? false + :insert-missing-whitespace? true + :align-associative? false}) + +(defn merge-cljfmt + [fmt] + (as-> fmt $ + (update $ :indents merge-default-indents) + (merge default-fmt $))) + +(defn- read-cljfmt + [s] + (try + (as-> s $ + (parse-clj-edn $) + (merge-cljfmt $)) + (catch js/Error e + {:error (.-message e)}))) + +(defn- reformat-string [range-text {:keys [align-associative? + remove-multiple-non-indenting-spaces?] :as config}] + (let [cljfmt-options (:cljfmt-options config) + trim-space-between? (or remove-multiple-non-indenting-spaces? + (:remove-multiple-non-indenting-spaces? cljfmt-options))] + (if (or align-associative? + (:align-associative? cljfmt-options)) + (pez-cljfmt/reformat-string range-text (-> cljfmt-options + (assoc :align-associative? true) + (dissoc :remove-multiple-non-indenting-spaces?))) + (cljfmt/reformat-string range-text (-> cljfmt-options + (assoc :remove-multiple-non-indenting-spaces? + trim-space-between?)))))) + +(defn format-text + [{:keys [range-text eol config] :as m}] + (try + (let [formatted-text (-> range-text + (reformat-string config) + (clojure.string/replace #"\r?\n" eol))] + (assoc m :range-text formatted-text)) + (catch js/Error e + (assoc m :error (.-message e))))) + +(comment + {:eol "\n" :all-text "[:foo\n\n(foo)(bar)]" :idx 6} + (def s "[:foo\n\n(foo\n(bar))]") + #_(def s "(defn\n0\n#_)") + (format-text #_s + {:range-text s + :eol "\n" + :config {:cljfmt-options + {:remove-surrounding-whitespace? false + :indents {"foo" [["inner" 0]]} + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false + :align-associative? true}}})) + +(defn extract-range-text + [{:keys [all-text range]}] + (subs all-text (first range) (last range))) + +(defn current-line-empty? + "Figure out if `:current-line` is empty" + [{:keys [current-line]}] + (some? (re-find #"^[\s,]*$" current-line))) + + +(defn indent-before-range + "Figures out how much extra indentation to add based on the length of the line before the range" + [{:keys [all-text range]}] + (let [start (first range) + end (last range)] + (if (= start end) + 0 + (-> (subs all-text 0 (first range)) + (util/split-into-lines) + (last) + (count))))) + + +(defn add-head-and-tail + "Splits `:all-text` at `:idx` in `:head` and `:tail`" + [{:keys [all-text idx] :as m}] + (-> m + (assoc :head (subs all-text 0 idx) + :tail (subs all-text idx)))) + + +(defn add-current-line + "Finds the text of the current line in `text` from cursor position `index`" + [{:keys [head tail] :as m}] + (-> m + (assoc :current-line + (str (second (re-find #"\n?(.*)$" head)) + (second (re-find #"^(.*)\n?" tail)))))) + + +(defn- normalize-indents + "Normalizes indents based on where the text starts on the first line" + [{:keys [range-text eol] :as m}] + (let [indent-before (apply str (repeat (indent-before-range m) " ")) + lines (clojure.string/split range-text #"\r?\n(?!\s*;)" -1)] + (assoc m :range-text (clojure.string/join (str eol indent-before) lines)))) + + +(defn index-for-tail-in-range + "Find index for the `tail` in `text` disregarding whitespace" + [{:keys [range-text range-tail on-type] :as m}] + (let [leading-space-length (count (re-find #"^[ \t,]*" range-tail)) + space-sym (str "@" (gensym "ESPACEIALLY") "@") + tail-pattern (-> range-tail + (clojure.string/replace #"[\]\)\}\"]" (str "$&" space-sym)) + (util/escape-regexp) + (clojure.string/replace #"^[ \t,]+" "") + (clojure.string/replace #"[\s,]+" "[\\s,]*") + (clojure.string/replace space-sym " ?")) + tail-pattern (if (and on-type (re-find #"^\r?\n" range-tail)) + (str "(\\r?\\n)+" tail-pattern) + tail-pattern) + pos (util/re-pos-first (str "[ \\t]{0," leading-space-length "}" tail-pattern "$") range-text)] + (assoc m :new-index pos))) + +(defn format-text-at-range + "Formats text from all-text at the range" + [{:keys [range idx] :as m}] + (let [indent-before (indent-before-range m) + padding (apply str (repeat indent-before " ")) + range-text (extract-range-text m) + padded-text (str padding range-text) + range-index (- idx (first range)) + tail (subs range-text range-index) + formatted-m (format-text (assoc m :range-text padded-text)) + formatted-text (subs (:range-text formatted-m) indent-before)] + (-> (assoc formatted-m + :range-text formatted-text + :range-tail tail)))) + +(comment + (format-text-at-range {:all-text " '([]\n[])" + :idx 7 + :on-type true + :head " '([]\n" + :tail "[])" + :current-line "[])" + :range [4 9]}) + (format-text-at-range {:eol "\n" + :all-text "[:foo\n\n(foo)(bar)]" + :idx 6 + :range [0 18]})) + + +(defn add-indent-token-if-empty-current-line + "If `:current-line` is empty add an indent token at `:idx`" + [{:keys [head tail range] :as m}] + (let [indent-token "0" + new-range [(first range) (inc (last range))]] + (if (current-line-empty? m) + (let [m1 (assoc m + :all-text (str head indent-token tail) + :range new-range)] + (assoc m1 :range-text (extract-range-text m1))) + m))) + + +(defn remove-indent-token-if-empty-current-line + "If an indent token was added, lets remove it. Not forgetting to shrink `:range`" + [{:keys [range-text range new-index] :as m}] + (if (current-line-empty? m) + (assoc m :range-text (str (subs range-text 0 new-index) (subs range-text (inc new-index))) + :range [(first range) (dec (second range))]) + m)) + +(def trailing-bracket_symbol "_calva-fmt-trail-symbol_") +(def trailing-bracket_pattern (re-pattern (str "_calva-fmt-trail-symbol_\\)$"))) + +(defn add-trail-symbol-if-comment + "If the `range-text` is a comment, add a symbol at the end, preventing the last paren from folding" + [{:keys [range all-text config idx] :as m}] + (let [keep-trailing-bracket-on-own-line? + (and (:keep-comment-forms-trail-paren-on-own-line? config) + (:comment-form? config))] + (if keep-trailing-bracket-on-own-line? + (let [range-text (extract-range-text m) + new-range-text (clojure.string/replace + range-text + #"\n{0,1}[ \t,]*\)$" + (str "\n" trailing-bracket_symbol ")")) + added-text-length (- (count new-range-text) + (count range-text)) + new-range-end (+ (second range) added-text-length) + new-all-text (str (subs all-text 0 (first range)) + new-range-text + (subs all-text (second range))) + new-idx (if (>= idx (- (second range) 1)) + (+ idx added-text-length) + idx)] + (-> m + (assoc :all-text new-all-text + :range-text new-range-text + :idx new-idx) + (assoc-in [:range 1] new-range-end))) + m))) + +(defn remove-trail-symbol-if-comment + "If the `range-text` is a comment, remove the symbol at the end" + [{:keys [range range-text new-index idx config] :as m} original-range] + (let [keep-trailing-bracket-on-own-line? + (and (:keep-comment-forms-trail-paren-on-own-line? config) + (:comment-form? config))] + (if keep-trailing-bracket-on-own-line? + (let [new-range-text (clojure.string/replace + range-text + trailing-bracket_pattern + ")")] + (-> m + (assoc :range-text new-range-text + :new-index (if (>= idx (- (second range) 1)) + (- (count new-range-text) + (- (second range) idx)) + new-index) + :range original-range))) + m))) + +(defn format-text-at-idx + "Formats the enclosing range of text surrounding idx" + [{:keys [range] :as m}] + (-> m + (update-in [:config :cljfmt-options] merge-cljfmt) + (add-trail-symbol-if-comment) + (add-head-and-tail) + (add-current-line) + (add-indent-token-if-empty-current-line) + (format-text-at-range) + (index-for-tail-in-range) + (remove-indent-token-if-empty-current-line) + (remove-trail-symbol-if-comment range))) + +(defn format-text-at-idx-on-type + "Relax formating some when used as an on-type handler" + [m] + (-> m + (assoc :on-type true) + (assoc-in [:config :cljfmt-options :remove-surrounding-whitespace?] false) + (assoc-in [:config :cljfmt-options :remove-trailing-whitespace?] false) + (assoc-in [:config :cljfmt-options :remove-consecutive-blank-lines?] false) + (format-text-at-idx))) + +(defn- js-cljfmt-options->clj [^js opts] + (let [indents (.-indents opts)] + (-> opts + (cljify) + (assoc :indents (->> indents + js->clj + (reduce-kv (fn [m k v] + (let [new-v (reduce (fn [acc x] + (conj acc [(keyword (first x)) (second x)])) + [] + v)] + (if (.startsWith k "#") + (let [regex-string (subs k 2 (- (count k) 1))] + (assoc m (re-pattern regex-string) new-v)) + (assoc m (symbol k) new-v)))) + {})))))) + +(defn- parse-cljfmt-options-string [^js m] + (let [conf (.-config m) + edn (aget conf "cljfmt-options-string")] + (-> m + (cljify) + (assoc-in [:config :cljfmt-options] (parse-clj-edn edn))))) + +(defn format-text-bridge + [^js m] + (-> m + (parse-cljfmt-options-string) + (update-in [:config :cljfmt-options] merge-cljfmt) + (format-text))) + +(defn format-text-at-range-bridge + [^js m] + (-> m + (parse-cljfmt-options-string) + (update-in [:config :cljfmt-options] merge-cljfmt) + (format-text-at-range))) + +(defn format-text-at-idx-bridge + [^js m] + (-> m + (parse-cljfmt-options-string) + (format-text-at-idx))) + +(defn format-text-at-idx-on-type-bridge + [^js m] + (-> m + (parse-cljfmt-options-string) + (format-text-at-idx-on-type))) + +(defn merge-cljfmt-from-string-js-bridge + [^js s] + (-> s + read-cljfmt + jsify)) + +(defn merge-cljfmt-js-bridge + [^js fmt] + (-> fmt + js-cljfmt-options->clj + merge-cljfmt + jsify)) + +(comment + (:range-text (format-text-at-idx-on-type {:all-text " '([]\n[])" :idx 7}))) + +(comment + {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false + :insert-missing-whitespace? true + :align-associative? true}) diff --git a/src/cljs-lib/src/calva/fmt/inferer.cljs b/src/cljs-lib/src/calva/fmt/inferer.cljs new file mode 100644 index 0000000..69015f3 --- /dev/null +++ b/src/cljs-lib/src/calva/fmt/inferer.cljs @@ -0,0 +1,130 @@ +(ns calva.fmt.inferer + (:require ["parinfer" :as parinfer] + [calva.js-utils :refer [cljify jsify]] + [calva.fmt.editor :as editor])) + +(defn infer-parens + "Calculate the edits needed for infering parens in `text`, + and where the cursor should be placed to 'stay' in the right place." + [{:keys [text line character previous-line previous-character]}] + (let [options {:cursorLine line + :cursorX character + :prevCursorLine previous-line + :prevCursorX previous-character} + result (cljify (parinfer/indentMode text (jsify options)))] + (jsify + (if (:success result) + {:success true + :line (:cursorLine result) + :character (:cursorX result) + :edits (editor/raplacement-edits-for-diffing-lines text (:text result))} + {:success false + :error-msg (get-in result [:error :message])})))) + +(defn infer-parens-bridge + [^js m] + (infer-parens (cljify m))) + + +(comment + (let [o (jsify {:cursorLine 1 :cursorX 13}) + result (parinfer/indentMode " (foo []\n (bar)\n (baz)))" o)] + (cljify result)) + + (infer-parens {:text " (foo []\n (bar)\n (baz)))" + :line 2 + :character 13}) + (infer-parens {:text "(f)))" + :line 0 + :character 2})) + +(defn infer-indents + "Calculate the edits needed for infering indents in `text`, + and where the cursor should be placed to 'stay' in the right place." + [{:keys [text line character previous-line previous-character changes]}] + (let [options {:cursorLine line :cursorX character + :prevCursorLine previous-line + :prevCursorX previous-character + :changes (mapv (fn [change] + {:lineNo (:line change) + :x (:character change) + :oldText (:old-text change) + :newText (:new-text change)}) + changes)} + result (cljify (parinfer/smartMode text (jsify options)))] + (jsify + (if (:success result) + {:success true + :line (:cursorLine result) + :character (:cursorX result) + :edits (editor/raplacement-edits-for-diffing-lines text (:text result))} + {:success false + :error-msg (get-in result [:error :message])})))) + +(defn infer-indents-bridge + [^js m] + (infer-indents (cljify m))) + + +(comment ;; SCRAP + (let [o (jsify {:cursorLine 1 :cursorX 4 + :changes {:lineNo 1 :x 0 :oldText "" :newText " "}}) + result (parinfer/parenMode " (defn a []\n (foo []\n (bar)\n (baz)))" o)] + (cljify result)) + + (let [o (jsify {:cursorLine 1 + :cursorX 3 + :prevCursorLine 1 + :prevCursorX 2 + :changes [{:lineNo 1 + :x 2 + :oldText "" + :newText " "}]}) + result (parinfer/parenMode "(comment\n (foo bar\n baz))" o)] + (cljify result)) + + (let [o (jsify {:cursorLine 1 + :cursorX 0 + :prevCursorLine 0 + :prevCursorX 8 + :changes [{:lineNo 0 + :x 8 + :oldText ")" + :newText "\n)"}]}) + result (parinfer/smartMode "(--> foo\n)" o)] + (cljify result)) + + (let [o (jsify {:cursorLine 1 + :cursorX 3 + :prevCursorLine 1 + :prevCursorX 2}) + result (parinfer/parenMode "(comment\n (foo bar\n baz))" o)] + (cljify result)) + + (infer-indents {:text "(comment \n (foo bar \n baz))" + :line 1 + :character 3 + :previous-line 1 + :previous-character 2 + :changes [{:line 1 + :character 2 + :old-text "" + :new-text " "}]}) + + (infer-indents {:text "(comment\n\n (foo bar \n baz))" + :line 1 + :character 0 + :previous-line 1 + :previous-character 0 + :changes [{:line 0 + :character 8 + :old-text "\n (foo bar \n baz))" + :new-text "\n\n (foo bar \n baz))"}]}) + + (infer-indents {:text " (defn a []\n (foo []\n (bar)\n (baz)))" + :line 1 + :character 4 + :changes [{:line 4 + :character 0 + :old-text "" + :new-text " "}]})) diff --git a/src/cljs-lib/src/calva/fmt/playground.cljs b/src/cljs-lib/src/calva/fmt/playground.cljs new file mode 100644 index 0000000..ea48e7f --- /dev/null +++ b/src/cljs-lib/src/calva/fmt/playground.cljs @@ -0,0 +1,158 @@ +(ns calva.fmt.playground + (:require [cljfmt.core :as cljfmt] + #_[zprint.core :refer [zprint-str]] + ["parinfer" :as parinfer] + [calva.js-utils :refer [cljify jsify]] + [calva.fmt.util :as util])) + + +(comment + (foo + ;; + bar + baz)) + +(comment + (cond) + (cljfmt/reformat-string "(cond foo\n)\n\n(cond foo\nbar)" + {:remove-surrounding-whitespace? false}) + + (cljfmt/reformat-string "(-> foo\nbar\n)\n(foo bar\nbaz\n)" + {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false}) + + (cljfmt/reformat-string "(let [x y\na b]\nbar\n)\n\n(-> foo\nbar\n)\n\n(foo bar\nbaz\n)" + {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false + :indents ^:replace {#".*" [[:inner 0]]}}) + + (cljfmt/reformat-string " '([] +[])" {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false}) + + + (def str "(defn \n\n)") + + (cljfmt/reformat-string str {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false}) + + (cljfmt/reformat-string "(foo + ;; + bar + baz)" + {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :remove-consecutive-blank-lines? false}) + + (cljfmt/reformat-string + "(foo + +)" + {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false + :indentation? true}) + + (cljfmt/reformat-string "(bar\n \n)" + {:remove-surrounding-whitespace? false + :remove-trailing-whitespace? false}) + + (cljfmt/reformat-string "(ns ui-app.re-frame.db) + +(def default-db #::{:page :home})") + + (cljfmt/reformat-string + "(defn bar\n [x]\n\n baz)") + + (zprint-str "(defn bar\n [x]\n\n baz)" + {:style :community + :parse-string-all? true + :fn-force-nl #{:arg1-body}}) + + (cljfmt/reformat-string + "(defn bar\n [x]\n\n baz)") + + "(defn bar\n [x]\n \n baz)" + + (cljfmt/reformat-string + ";; foo +(defn foo [x] + (* x x)) + 0") + + (div + ;; foo + [:div] + ;; bar + [:div])) + + +(comment + (parinfer/indentMode " (foo [] + (bar) + (baz)))" + (jsify {:cursorLine 2 + :cursorX 13}))) + +(comment + (cljfmt/reformat-string + "{:foo false + :bar false +:baz #\"^[a-z]\"}")) + +(comment + (def t "(when something + body) + +(defn f [x] + body) + +(defn f + [x] + body) + +(defn many-args [a b c + d e f] + body) + +(defn multi-arity + ([x] + body) + ([x y] + body)) + +(let [x 1 + y 2] + body) + +[1 2 3 + 4 5 6] + +{:key-1 v1 + :key-2 v2} + +#{a b c + d e f} + +(or (condition-a) + (condition-b)) + +(filter even? + (range 1 10)) + +(clojure.core/filter even? + (range 1 10)) + +(filter + even? + (range 1 10))") + + (def f + (cljfmt/reformat-string t {:indents {#"^\w" [[:inner 0]]}})) + + (= t f) + (pr-str f) + (println f)) \ No newline at end of file diff --git a/src/cljs-lib/src/calva/fmt/util.cljs b/src/cljs-lib/src/calva/fmt/util.cljs new file mode 100644 index 0000000..60c2f87 --- /dev/null +++ b/src/cljs-lib/src/calva/fmt/util.cljs @@ -0,0 +1,38 @@ +(ns calva.fmt.util + (:require [clojure.string])) + + +(defn log + "logs out the object `o` excluding any keywords in `exclude-kws`" + [o & exlude-kws] + (println (pr-str (if (map? o) (apply dissoc o exlude-kws) o))) + o) + + +(defn escape-regexp + "Escapes regexp characters in `s`" + [s] + (clojure.string/replace s #"([.*+?^${}()|\[\]\\])" "\\$1")) + + +(defn current-line + "Finds the text of the current line in `text` from cursor position `index`" + [text index] + (let [head (subs text 0 index) + tail (subs text index)] + (str (second (re-find #"\n?(.*)$" head)) + (second (re-find #"^(.*)\n?" tail))))) + + +(defn re-pos-first + "Find position of first match of `re` in `s`" + [re s] + (if-let [m (.match s re)] + (.-index m) + -1)) + + +(defn split-into-lines + [s] + (clojure.string/split s #"\r?\n" -1)) + diff --git a/src/cljs-lib/src/calva/js2cljs/converter.cljs b/src/cljs-lib/src/calva/js2cljs/converter.cljs new file mode 100644 index 0000000..db41f65 --- /dev/null +++ b/src/cljs-lib/src/calva/js2cljs/converter.cljs @@ -0,0 +1,50 @@ +(ns calva.js2cljs.converter + (:require [js-cljs.core :as jsc])) + +(defn convert [js-string] + (let [debug (atom nil)] + (try + {:result + (jsc/parse-str js-string {:zprint-opts {:style [:community] + :parse {:interpose "\n\n"} + :width 60 + :pair {:nl-separator? true}} + :format-opts {:debug debug}})} + (catch :default e + {:error {:message "Error parsing JS file" + :number-of-parsed-lines (count (.split (subs js-string 0 (:start @debug)) "\n")) + :exception {:name (.-name e) + :message (.-message e)}}})))) + +(defn convert-bridge [js-string] + (convert js-string)) + +(comment + (convert "var MongoClient = require('mongodb').MongoClient; +var url = \"mongodb://localhost:27017/mydb\"; + +MongoClient.connect(url, function(err, db) { + if (err) throw err; + console.log(\"Database created!\"); + db.close(); +});") + + (convert "foo; + bar; + import * as foo from 'foo'") + + (jsc/parse-str "a++") + + (println (jsc/parse-str " + const sdk = new ChartsEmbedSDK({ + baseUrl: 'https://charts.mongodb.com/charts-mongodb-gtywi' + }); + + const chart = sdk.createChart({ chartId: '7f535ee7-2074-4350-9f94-237277b94391' }); + chart.render(document.getElementById('chart')); +" {:zprint-opts {:style [:community] + :parse {:interpose "\n\n"} + :width 60 + :pair {:nl-separator? true}} + :format-opts {:debug (atom nil)}})) + ) \ No newline at end of file diff --git a/src/cljs-lib/src/calva/js_utils.cljs b/src/cljs-lib/src/calva/js_utils.cljs new file mode 100644 index 0000000..79df04c --- /dev/null +++ b/src/cljs-lib/src/calva/js_utils.cljs @@ -0,0 +1,11 @@ +(ns calva.js-utils + (:require [cljs.reader])) + +(defn jsify + "Converts clojure data to js data" + [o] + (clj->js o :keyword-fn (fn [kw] (str (symbol kw))))) + +(defn cljify [o] + (js->clj o :keywordize-keys true)) + diff --git a/src/cljs-lib/src/calva/main.cljs b/src/cljs-lib/src/calva/main.cljs new file mode 100644 index 0000000..a3748e6 --- /dev/null +++ b/src/cljs-lib/src/calva/main.cljs @@ -0,0 +1,4 @@ +(ns calva.main) + +(defn main [& args] + (js/console.log "Hello from calva-lib")) diff --git a/src/cljs-lib/src/calva/parse.cljs b/src/cljs-lib/src/calva/parse.cljs new file mode 100644 index 0000000..ca53373 --- /dev/null +++ b/src/cljs-lib/src/calva/parse.cljs @@ -0,0 +1,65 @@ +(ns calva.parse + (:require [cljs.reader] + [cljs.tools.reader :as tr] + [cljs.tools.reader.reader-types :as rt] + [clojure.string :as str] + [calva.js-utils :refer [jsify]])) + +(defn parse-edn + "Parses out the first form from `s`. + `s` needs to be a string representation of valid EDN. + Returns the parsed form." + [s] + (cljs.reader/read-string {:default #(str "#" %1 %2)} s)) + +(defn parse-edn-js [s] + (jsify (parse-edn s))) + +(defn parse-edn-js-bridge [s] + (parse-edn-js s)) + +(defn parse-forms + "Parses out all top level forms from `s`. + Returns a vector with the parsed forms." + [s] + (let [pbr (rt/string-push-back-reader (str/replace s #"#=\(" "nil #_("))] + (loop [parsed-forms []] + (let [parsed-form (tr/read {:eof 'CALVA-EOF + :read-cond :preserve} pbr)] + (if (= parsed-form 'CALVA-EOF) + parsed-forms + (recur (conj parsed-forms parsed-form))))))) + +(defn parse-forms-js [s] + (jsify (parse-forms s))) + +(defn parse-forms-js-bridge [s] + (parse-forms-js s)) + +(defn parse-clj-edn + "Reads edn (with regexp tags)" + ; https://ask.clojure.org/index.php/8675/cljs-reader-read-string-fails-input-clojure-string-accepts + [s] (tr/read-string s)) + +;[[ar gu ment] {:as extras, :keys [d e :s t r u c t u r e d]}] +(comment + (meta (:indents (parse-clj-edn "{:indents ^:replace {}}"))) + (parse-forms-js-bridge "(deftest fact-rec-test\n (testing \"returns 1 when passed 1\"\n (is (= 1 (do (println \"hello\") #break (core/fact-rec 1))))))") + (= [:a {:foo [(quote bar)], :bar (quote foo)}] + [:a {:foo ['bar] :bar 'foo}]) + (parse-forms "(ns calva.js-utils + (:require [cljs.reader] + [cljs.tools.reader :as tr] + [cljs.tools.reader.reader-types :as rt] + [cljs.test :refer [is]])) + +(defn jsify [o] + (clj->js o)) + +(defn cljify [o] + (js->clj o :keywordize-keys true))") + (parse-forms "(ns ace2.legacy.bink + (:gen-class) + (:require [clojure.java.io :as io]) + (:import (java.io RandomAccessFile))) +(defn foo [] (println \"whee\"))")) diff --git a/src/cljs-lib/src/calva/pprint/printer.cljs b/src/cljs-lib/src/calva/pprint/printer.cljs new file mode 100644 index 0000000..6a0e5ee --- /dev/null +++ b/src/cljs-lib/src/calva/pprint/printer.cljs @@ -0,0 +1,66 @@ +(ns calva.pprint.printer + (:require [zprint.core :refer [zprint-str]] + [calva.js-utils :refer [jsify cljify]] + [clojure.string])) + +(defn pretty-print + "Parses the string `s` as EDN and returns it pretty printed as a string. + Accepts that s is an EDN form already, and skips the parsing, if so. + Formats the result to fit the width `w`." + [s opts] + (let [result (try + {:value + (zprint-str s (assoc opts :parse-string? (string? s)))} + (catch js/Error e + {:value s + :error (str "Plain printing, b/c pprint failed. (" (.-message e) ")")}))] + result)) + + +(defn pretty-print-js [s {:keys [width, maxLength, maxDepth]}] + (let [opts (into {} (remove (comp nil? val) {:width width + :max-length maxLength + :max-depth maxDepth}))] + (jsify (pretty-print s opts)))) + +(defn pretty-print-js-bridge [s ^js opts] + (pretty-print-js s (cljify opts))) + + +;; SCRAP +(comment + (pretty-print "[ [ [:foo + ]] ]" nil) + ;; => {:value "[[[:foo]]]"} + (pretty-print [[:shallow]] {:max-depth 1}) + ;; => {:value "[##]"} + (pretty-print [[[[[[[[:deeper]]]]]]]] {:max-depth 4}) + ;; => {:value "[[[[##]]]]"} + + (def ignores [#_#_#_#_#_:span "This" "is" "How" "it" "Works"]) + (:value (pretty-print ignores nil)) + ;; => "[(quote \"Works\")]" + + (def str-ignores "[ #_ #_ #_#_#_:span \"This\" \"is\" \"How\" \"it\" \"Works\" ]") + (:value (pretty-print str-ignores nil)) + ;; => "[#_#_#_#_#_:span \"This\" \"is\" \"How\" \"it\" \"Works\"]" + + (def struct '(let [r :r + this-page :this-page] + [:div.grid-x.grid-margin-x.grid-margin-y + [:div.cell.align-center.margin-top.show-for-medium + [:a#foo.button + {:href "#how-it-works"} + [#_#_#_#_#_:span "This" "is" "How" "it" "Works"]] + [:a#bar.button + {:on-click #(citrus/broadcast! r :submit this-page)} + "Send"]]])) + (:value (pretty-print struct nil)) + ;; => "(let [r :r\n this-page :this-page]\n [:div.grid-x.grid-margin-x.grid-margin-y\n [:div.cell.align-center.margin-top.show-for-medium\n [:a#foo.button {:href \"#how-it-works\"} [\"Works\"]]\n [:a#bar.button {:on-click (fn* [] (citrus/broadcast! r :submit this-page))}\n \"Send\"]]])" + + (:value (pretty-print struct {:max-length 2})) + ;; => "(let [r :r ...] ...)" + + (:value (pretty-print struct {:max-length 3})) + ;; => "(let [r :r\n this-page :this-page]\n [:div.grid-x.grid-margin-x.grid-margin-y\n [:div.cell.align-center.margin-top.show-for-medium\n [:a#foo.button {:href \"#how-it-works\"} [\"Works\"]]\n [:a#bar.button {:on-click (fn* [] (citrus/broadcast! r :submit ...))}\n \"Send\"]]])" + ) \ No newline at end of file diff --git a/src/cljs-lib/src/calva/state.cljs b/src/cljs-lib/src/calva/state.cljs new file mode 100644 index 0000000..cee0a2c --- /dev/null +++ b/src/cljs-lib/src/calva/state.cljs @@ -0,0 +1,20 @@ +(ns calva.state) + +(defonce ^:private state (atom {})) + +(defn set-state-value! [key value] + (swap! state assoc key value)) + +(defn remove-state-value! [key] + (swap! state dissoc key)) + +(defn get-state-value [key] + (get @state key)) + +(defn get-state [] + @state) + +(comment + (set-state-value! "hello" "world") + (get-state) + (remove-state-value! "hello")) \ No newline at end of file diff --git a/src/cljs-lib/src/js_cljs/core.cljs b/src/cljs-lib/src/js_cljs/core.cljs new file mode 100644 index 0000000..2f99c2f --- /dev/null +++ b/src/cljs-lib/src/js_cljs/core.cljs @@ -0,0 +1,418 @@ +(ns js-cljs.core + (:require ["acorn" :refer [parse]] + [zprint.core :as zprint] + [clojure.string :as str])) + +(defmulti parse-frag (fn [step state] + (when (and step (:debug state)) (reset! (:debug state) step)) + (:type step))) + +(defn- block [bodies state sep] + (let [ops (->> bodies + (map #(parse-frag % state)) + (remove nil?)) + body (str/join sep ops) + locals (:locals state)] + (cond + (and locals (seq @locals)) (str "(let [" (str/join " " (mapcat identity @locals)) "] " body ")") + (-> state :single? not (or (-> ops count (= 1)))) body + :else (str "(do " body ")")))) + +(defmethod parse-frag "Program" [step state] + (block (:body step) (assoc state :root? true) "\n")) + +(defmethod parse-frag "BlockStatement" [step state] + (block (:body step) (assoc state :root? false :locals (atom [])) " ")) + +(defmethod parse-frag "ExpressionStatement" [step state] + (parse-frag (:expression step) state)) + +(defmethod parse-frag "ForStatement" [{:keys [init test update body]} state] + (let [[id val] (when init (parse-frag (-> init :declarations first) (assoc state :root? false))) + test (if test + (parse-frag test (assoc state :single? true)) + "true") + add-let? (and init (not (:locals state)))] + (str (when add-let? + (str "(let [" id " " val "] ")) + "(while " test + " " (block (:body body) + (assoc state :root? false :single? false :locals (atom [])) + " ") + (when update (str " " (parse-frag update (assoc state :single? false)))) + ")" + (when add-let? ")")))) + +(defn- get-operator [operator] + (case operator + "&&" "and" + "||" "or" + "==" "=" + "===" "=" + "!=" "not=" + "!==" "not=" + "!" "not" + operator)) + +(defn- binary-exp [{:keys [left right operator]} state] + (let [state (assoc state :single? true) + left (parse-frag left state) + right (parse-frag right state)] + (if (= operator "??") + (str "(if (some? " left ") " left " " right ")") + (str "(" (get-operator operator) " " left " " right ")")))) + +(defmethod parse-frag "UnaryExpression" [{:keys [operator argument]} state] + (let [operator (get-operator operator)] + (str "(" operator " " (parse-frag argument (assoc state :single? true)) ")"))) + +(defmethod parse-frag "BinaryExpression" [step state] (binary-exp step state)) +(defmethod parse-frag "LogicalExpression" [step state] (binary-exp step state)) + +(defmethod parse-frag "Literal" [{:keys [value regex] :as p} _] + (if regex + (if-let [flags (-> regex :flags not-empty)] + (str "#" (pr-str (str "(?" flags ")" (:pattern regex)))) + (str "#" (pr-str (:pattern regex)))) + (pr-str value))) + +(defmethod parse-frag "Identifier" [{:keys [name]} _] name) + +(defn- call-expr [{:keys [callee arguments]} state] + (let [callee (parse-frag callee (assoc state :single? true :special-js? true)) + args (mapv #(parse-frag % (assoc state :single? true)) arguments) + [non-rest [[fst] & rst]] (split-with (complement vector?) args) + rest (cond + (seq rst) [(str "(concat " fst " [" (str/join " " rst) "])")] + fst [fst])] + (if (string? callee) + (if rest + (str "(apply " (str/join " " (concat [callee] non-rest rest)) ")") + (str "(" (->> args (cons callee) (str/join " ")) ")")) + (str "(." (second callee) " " (first callee) " " (str/join " " args) + ")")))) +(defmethod parse-frag "CallExpression" [prop state] (call-expr prop state)) +(defmethod parse-frag "NewExpression" [props state] + (call-expr (update-in props [:callee :name] str ".") state)) + +(defn- if-then-else [{:keys [test consequent alternate]} state] + (if alternate + (str "(if " + (parse-frag test (assoc state :single? true)) + " " (parse-frag consequent (assoc state :single? true)) + " " (parse-frag alternate (assoc state :single? true)) + ")") + (str "(when " + (parse-frag test (assoc state :single? true)) + " " (parse-frag consequent state) + ")"))) + +(defmethod parse-frag "IfStatement" [element state] (if-then-else element state)) +(defmethod parse-frag "ConditionalExpression" [element state] (if-then-else element state)) + +(defn- random-identifier [] (gensym "-temp-")) +(defn- to-obj-params [fun param] + (map (fn [[k v]] (str k " (.-" v " " fun ")")) param)) + +(defn- to-default-param [[fun default]] + [fun (str "(if (undefined? " fun ") " default " " fun ")")]) + +(defn- normalize-params [params state] + (let [params (map #(parse-frag % state) params) + params-detailed (for [param params] + (if (vector? param) + (if (-> param first vector?) + (let [id (random-identifier)] + {:fun id :extracts-to (to-obj-params id param)}) + {:fun (first param) :extracts-to (to-default-param param)}) + {:fun param})) + let-params (->> params-detailed (mapcat :extracts-to) (filter identity))] + {:params (->> params-detailed (map :fun) (str/join " ")) + :lets (when (seq let-params) (str/join " " let-params))})) + +(defmethod parse-frag "FunctionDeclaration" [{:keys [id params body]} state] + (let [body (parse-frag body (assoc state :single? false)) + {:keys [params lets]} (normalize-params params state) + norm-body (if lets + (str "(let [" lets "] " body ")") + body)] + (str "(defn " (parse-frag id state) " [" params "] " norm-body ")"))) + +(defn- parse-fun [{:keys [id params body]} state] + (let [params (->> params (map #(parse-frag % state)) (str/join " ")) + body (parse-frag body (assoc state :single? false))] + (str "(fn" + (when-let [name (some-> id (parse-frag state))] + (str " " name)) + " [" params "] " body ")"))) + +(defmethod parse-frag "FunctionExpression" [step state] (parse-fun step state)) +(defmethod parse-frag "ArrowFunctionExpression" [step state] (parse-fun step state)) + +(defmethod parse-frag "ReturnStatement" [{:keys [argument]} state] + (if argument + (parse-frag argument state) + "(js* \"return\")")) + +(defmethod parse-frag "ForOfStatement" [{:keys [left right body]} state] + (str "(doseq [" (-> left :declarations first :id :name) + " " (parse-frag right (assoc state :single? true)) + "] " (parse-frag body (assoc state :single? false)) ")")) + +(defmethod parse-frag "ForInStatement" [{:keys [left right body]} state] + (str "(doseq [" (-> left :declarations first :id :name) + " (js/Object.keys " (parse-frag right (assoc state :single? true)) + ")] " (parse-frag body (assoc state :single? false)) ")")) + +(defn- template-lit [tag {:keys [expressions quasis]} state] + (when tag + (swap! (:cljs-requires state) conj '[shadow.cljs.modern :as modern])) + (let [state (assoc state :single? true) + elems (interleave quasis expressions) + parsed (mapv #(parse-frag % state) elems) + last (-> quasis peek (parse-frag state)) + parsed (cond-> parsed (not= last "\"\"") (conj last))] + (cond + tag (str "(modern/js-template " (parse-frag tag state) " " + (str/join " " parsed) ")") + (seq parsed) (str "(str " (str/join " " parsed) ")") + :else "\"\""))) + +(defmethod parse-frag "TaggedTemplateExpression" [{:keys [tag quasi]} state] + (template-lit tag quasi state)) +(defmethod parse-frag "TemplateLiteral" [prop state] + (template-lit nil prop state)) + +(defmethod parse-frag "TemplateElement" [{:keys [value]} _] (pr-str (:cooked value))) + +(defmethod parse-frag "ThrowStatement" [{:keys [argument]} state] + (str "(throw " (parse-frag argument state) ")")) + +(defmethod parse-frag "AssignmentExpression" [{:keys [operator left right] :as a} state] + (let [vars (parse-frag left (assoc state :single? true :special-js? true)) + val (parse-frag right (assoc state :single? true)) + attr (-> vars second delay) + obj (delay (let [f (first vars)] + (if (vector? f) (str "(.-" (second f) " " (first f) ")") f)))] + + (cond + (string? vars) + (str "(js* " (pr-str (str "~{} " operator " ~{}")) " " vars " " val ")") + + (:computed left) + (str "(aset " @obj " " @attr " " val ")") + + :else + (str "(aset " @obj " " (pr-str @attr) " " val ")")))) + +(defn- make-destr-def [[k v] val] + (str "(def " k " (.-" v " " val "))")) + +(defmethod parse-frag "VariableDeclaration" [{:keys [declarations]} state] + (let [declarations (mapv #(parse-frag % state) declarations)] + (when (:root? state) + (let [defs (for [[k v] declarations] + (if (vector? k) + (if (-> k count (= 1)) + (make-destr-def (first k) v) + (let [sym (random-identifier) + inner (map #(make-destr-def % sym) k)] + (str "(let [" sym " " v "] " (str/join " " inner) ")"))) + (if (and (string? v) (str/starts-with? v "(fn ")) + (str "(defn " k " " (subs v 4)) + (str "(def " k " " v ")"))))] + (str/join " " defs))))) + +(defmethod parse-frag "ContinueStatement" [_ _] "(js* \"continue\")") + +(defmethod parse-frag "VariableDeclarator" [{:keys [id init]} state] + (let [vars (:locals state) + init (if init + (parse-frag init (assoc state :single? true)) + "nil") + body [(parse-frag id (assoc state :single? true)) init]] + (if vars + (swap! vars conj body) + body))) + +(defmethod parse-frag "ObjectExpression" [{:keys [properties]} state] + (let [kvs (->> properties + (map #(parse-frag % (assoc state :single? true))) + (map (fn [[k v]] (str ":" k " " v))))] + (str "#js {" (str/join " " kvs) "}"))) + +(defmethod parse-frag "ArrayExpression" [{:keys [elements]} state] + (let [vals (map #(parse-frag % (assoc state :single? true)) elements)] + (str "#js [" (str/join " " vals) "]"))) + +(defmethod parse-frag "Property" [{:keys [key value]} state] + [(parse-frag key (assoc state :single? true)) + (parse-frag value (assoc state :single? true))]) + +(defmethod parse-frag "MemberExpression" [{:keys [object property computed] :as m} state] + (let [obj (parse-frag object state) + prop (parse-frag property state)] + (if (:special-js? state) + [obj prop] + (cond + (not computed) (str "(.-" prop " " obj ")") + (re-matches #"\"?\d+\"?" prop) (str "(nth " obj " " (js/parseInt prop) ")") + :else (str "(aget " obj " " prop ")"))))) + +(defmethod parse-frag "ObjectPattern" [{:keys [properties]} state] + (mapv #(parse-frag % (assoc state :single? true)) + properties)) + +(defmethod parse-frag "AssignmentPattern" [{:keys [left right]} state] + [(parse-frag left (assoc state :single? true)) + (parse-frag right (assoc state :single? true))]) + +(defmethod parse-frag "SpreadElement" [{:keys [argument]} state] + [(parse-frag argument state)]) + +(defn- gen-properties [class [property {:keys [get set]}]] + (str "(.defineProperty js/Object (.-prototype " class + ") " (pr-str property) " #js {" + (when get + (str ":get (fn [] " (str/replace-first get #".*this\]" "(this-as this"))) + (when set + (let [[_ params] (re-find #"\[this (.*)\]" set)] + (str ":set (fn [" params "] " + (str/replace-first set #".*this.*\]" "(this-as this")))) + ")})")) + +(defn- class-declaration [{:keys [id, superClass, body]} state] + (swap! (:cljs-requires state) conj '[shadow.cljs.modern :as modern]) + (let [class-name (parse-frag id state) + {:keys [constructor methods properties]} (parse-frag body state) + super (some-> superClass (parse-frag state)) + defclass (str "(modern/defclass " class-name + (when super (str " (extends " super ")")) + " " + (if constructor constructor "(constructor [this])") + (when (seq methods) + (->> methods (cons " Object") (str/join " "))) + ")")] + (cond-> defclass + properties (str (->> properties + (map #(gen-properties class-name %)) + (cons "") + (str/join " ")))))) + +(defmethod parse-frag "ClassDeclaration" [props state] (class-declaration props state)) +(defmethod parse-frag "ClassExpression" [props state] (class-declaration props state)) + +(defmethod parse-frag "ClassBody" [{:keys [body]} state] + (let [state (assoc state :js-class? true)] + (reduce (fn [acc b] + (case (:kind b) + "constructor" (assoc acc :constructor (parse-frag b state)) + "get" (assoc-in acc [:properties (-> b :key :name) :get] (parse-frag b state)) + "set" (assoc-in acc [:properties (-> b :key :name) :set] (parse-frag b state)) + (update acc :methods conj (parse-frag b state)))) + {:methods []} + body))) + +(defmethod parse-frag "MethodDefinition" [{:keys [key value]} state] + (let [{:keys [params body]} value + {:keys [lets params]} (normalize-params params state) + body (some->> (parse-frag body state) not-empty (str " ")) + norm-body (if lets + (str " (let [" lets "]" body ")") + body)] + (str "(" (parse-frag key state) + " [this" (cond->> params (seq params) (str " ")) + "]" + norm-body + ")"))) + +(defmethod parse-frag "ThisExpression" [_ state] + (if (:js-class? state) + "this" + "(js* \"this\")")) + +(defmethod parse-frag "TryStatement" [{:keys [block handler finalizer]} state] + (str "(try " (parse-frag block state) + (when handler + (str " (catch :default " (parse-frag (:param handler) state) + " " (parse-frag (:body handler) state) ")")) + (when finalizer + (str " (finally " (parse-frag finalizer state) ")")) + ")")) + +(defmethod parse-frag "SwitchStatement" [{:keys [discriminant cases]} state] + (let [state (assoc state :single? true) + test (parse-frag discriminant state) + cases (map #(parse-frag % state) cases)] + (str "(case " test + " " (str/join " " cases) + ")"))) + +(defmethod parse-frag "SwitchCase" [{:keys [test consequent]} state] + (let [body (block consequent state " ")] + (if test + (str (parse-frag test state) " " body) + body))) + +(defmethod parse-frag "ArrayPattern" [{:keys [elements]} state] + (str "[" + (->> elements + (map #(parse-frag % (assoc state :single? true))) + (str/join " ")) + "]")) + +(defmethod parse-frag "WhileStatement" [{:keys [test body]} state] + (str "(while " (parse-frag test (assoc state :single? true)) + " " (parse-frag body (assoc state :single? false)) + ")")) + +(defmethod parse-frag "BreakStatement" [_ _] nil) + +(defmethod parse-frag "RestElement" [{:keys [argument]} state] + (str "& " (parse-frag argument (assoc state :single? true)))) + +(defmethod parse-frag "UpdateExpression" [{:keys [operator prefix argument]} state] + (let [macro (if prefix + (str operator "~{}") + (str "~{}" operator))] + (str "(js* " (pr-str macro) " " (parse-frag argument (assoc state :single? true)) ")"))) + +(defmethod parse-frag :default [dbg state] + (tap> dbg) + (def t (:type dbg)) + (throw (ex-info (str "Not implemented: " (:type dbg)) + {:element (:type dbg)}))) + +#_(parse-str "a++") + +#_(from-js "a.b = 1") +#_(from-js "a[b] = 1") + +(defn- from-js [code] + (-> code + (parse #js {:ecmaVersion 2020}) + js/JSON.stringify + js/JSON.parse + (js->clj :keywordize-keys true))) + +(defn- add-requires [code requires] + (cond->> code + (seq requires) + (str "(ns your.ns (:require " (str/join " " requires) ")) "))) + +(defn parse-str + ([code] + (let [reqs (atom #{})] + (-> code + from-js + (parse-frag {:cljs-requires reqs :debug (atom nil)}) + (add-requires @reqs)))) + ([code opts] + (let [reqs (atom #{})] + (-> code + from-js + (parse-frag (assoc (:format-opts opts) :cljs-requires reqs)) + (add-requires @reqs) + (cond-> + (-> opts :zprint-opts :disable not) + (zprint/zprint-file-str "file: example.cljs" (:zprint-opts opts))))))) diff --git a/src/cljs-lib/src/pez_cljfmt/core.clj b/src/cljs-lib/src/pez_cljfmt/core.clj new file mode 100644 index 0000000..dd5b6d4 --- /dev/null +++ b/src/cljs-lib/src/pez_cljfmt/core.clj @@ -0,0 +1,5 @@ +(ns pez-cljfmt.core + (:require [clojure.java.io :as io])) + +(def read-resource* (comp read-string slurp io/resource)) +(defmacro read-resource [path] `'~(read-resource* path)) \ No newline at end of file diff --git a/src/cljs-lib/src/pez_cljfmt/core.cljs b/src/cljs-lib/src/pez_cljfmt/core.cljs new file mode 100644 index 0000000..75f0625 --- /dev/null +++ b/src/cljs-lib/src/pez_cljfmt/core.cljs @@ -0,0 +1,485 @@ +(ns pez-cljfmt.core + (:require [cljs.reader :as reader] + [clojure.zip :as zip] + [clojure.string :as str] + [pez-rewrite-clj.node :as n] + [pez-rewrite-clj.parser :as p] + [pez-rewrite-clj.zip :as z] + [pez-rewrite-clj.zip.base :as zb :refer [edn]] + [pez-rewrite-clj.zip.whitespace :as zw + :refer [append-space skip whitespace-or-comment?]]) + (:require-macros [pez-cljfmt.core :refer [read-resource]])) + +(def zwhitespace? + zw/whitespace?) + +(def zlinebreak? + zw/linebreak?) + +(def includes? + str/includes?) + +(defn- find-all [zloc p?] + (loop [matches [] + zloc zloc] + (if-let [zloc (z/find-next zloc zip/next p?)] + (recur (conj matches zloc) + (zip/next zloc)) + matches))) + +(defn- edit-all [zloc p? f] + (loop [zloc (if (p? zloc) (f zloc) zloc)] + (if-let [zloc (z/find-next zloc zip/next p?)] + (recur (f zloc)) + zloc))) + +(defn- transform [form zf & args] + (z/root (apply zf (edn form) args))) + +(defn- surrounding? [zloc p?] + (and (p? zloc) (or (nil? (zip/left zloc)) + (nil? (skip zip/right p? zloc))))) + +(defn- top? [zloc] + (and zloc (not= (z/node zloc) (z/root zloc)))) + +(defn- surrounding-whitespace? [zloc] + (and (top? (z/up zloc)) + (surrounding? zloc zwhitespace?))) + +(defn remove-surrounding-whitespace [form] + (transform form edit-all surrounding-whitespace? zip/remove)) + +(defn- element? [zloc] + (and zloc (not (whitespace-or-comment? zloc)))) + +(defn- reader-macro? [zloc] + (and zloc (= (n/tag (z/node zloc)) :reader-macro))) + +(defn- missing-whitespace? [zloc] + (and (element? zloc) + (not (reader-macro? (zip/up zloc))) + (element? (zip/right zloc)))) + +(defn insert-missing-whitespace [form] + (transform form edit-all missing-whitespace? append-space)) + +(defn- whitespace? [zloc] + (= (z/tag zloc) :whitespace)) + +(defn- comma? [zloc] + (= (z/tag zloc) :comma)) + +(defn- comment? [zloc] + (some-> zloc z/node n/comment?)) + +(defn- line-break? [zloc] + (or (zlinebreak? zloc) (comment? zloc))) + +(defn- skip-whitespace [zloc] + (skip zip/next whitespace? zloc)) + +(defn- skip-comma [zloc] + (skip zip/next comma? zloc)) + +(defn- count-newlines [zloc] + (loop [zloc zloc, newlines 0] + (if (zlinebreak? zloc) + (recur (-> zloc zip/right skip-whitespace) + (-> zloc z/string count (+ newlines))) + newlines))) + +(defn- final-transform-element? [zloc] + (= (z/next zloc) zloc)) + +(defn- consecutive-blank-line? [zloc] + (and (> (count-newlines zloc) 2) + (not (final-transform-element? zloc)))) + +(defn- remove-whitespace-and-newlines [zloc] + (if (zwhitespace? zloc) + (recur (zip/remove zloc)) + zloc)) + +(defn- replace-consecutive-blank-lines [zloc] + (-> zloc + z/next + zip/prev + remove-whitespace-and-newlines + z/next + (zip/insert-left (n/newlines 2)))) + +(defn remove-consecutive-blank-lines [form] + (transform form edit-all consecutive-blank-line? replace-consecutive-blank-lines)) + +(defn- indentation? [zloc] + (and (line-break? (zip/prev zloc)) (whitespace? zloc))) + +(defn- comment-next? [zloc] + (-> zloc zip/next skip-whitespace comment?)) + +(defn- should-indent? [zloc] + (and (line-break? zloc) (not (comment-next? zloc)))) + +(defn- should-unindent? [zloc] + (and (indentation? zloc) (not (comment-next? zloc)))) + +(defn unindent [form] + (transform form edit-all should-unindent? zip/remove)) + +(def ^:private start-element + {:meta "^", :meta* "#^", :vector "[", :map "{" + :list "(", :eval "#=", :uneval "#_", :fn "#(" + :set "#{", :deref "@", :reader-macro "#", :unquote "~" + :var "#'", :quote "'", :syntax-quote "`", :unquote-splicing "~@" + ;; hy-specific options + :j-table "@{" :j-array "@[" :j-buffer "@\"" + }) + +(defn- prior-line-string [zloc] + (loop [zloc zloc + worklist '()] + (if-let [p (zip/left zloc)] + (let [s (str (n/string (z/node p))) + new-worklist (cons s worklist)] + (if-not (includes? s "\n") + (recur p new-worklist) + (apply str new-worklist))) + (if-let [p (zip/up zloc)] + ;; newline cannot be introduced by start-element + (recur p (cons (start-element (n/tag (z/node p))) worklist)) + (apply str worklist))))) + +(defn- last-line-in-string [^String s] + (subs s (inc (.lastIndexOf s "\n")))) + +(defn- margin [zloc] + (-> zloc prior-line-string last-line-in-string count)) + +(defn- whitespace [width] + (n/whitespace-node (apply str (repeat width " ")))) + +(defn- coll-indent [zloc] + (-> zloc zip/leftmost margin)) + +(defn- index-of [zloc] + (->> (iterate z/left zloc) + (take-while identity) + (count) + (dec))) + +(defn- list-indent [zloc] + (if (> (index-of zloc) 1) + (-> zloc zip/leftmost z/right margin) + (coll-indent zloc))) + +(def indent-size 2) + +(defn- indent-width [zloc] + (case (z/tag zloc) + :list indent-size + :fn (inc indent-size))) + +(defn- remove-namespace [x] + (if (symbol? x) (symbol (name x)) x)) + +(defn pattern? [v] + (instance? js/RegExp v)) + +(defn- indent-matches? [key sym] + (cond + (symbol? key) (= key sym) + (pattern? key) (re-find key (str sym)))) + +(defn- token? [zloc] + (= (z/tag zloc) :token)) + +(defn- token-value [zloc] + (and (token? zloc) (z/sexpr zloc))) + +(defn- reader-conditional? [zloc] + (and (reader-macro? zloc) (#{"?" "?@"} (-> zloc z/down token-value str)))) + +(defn- form-symbol [zloc] + (-> zloc z/leftmost token-value)) + +(defn- index-matches-top-argument? [zloc depth idx] + (and (> depth 0) + (= (inc idx) (index-of (nth (iterate z/up zloc) depth))))) + +(defn- fully-qualify-symbol [possible-sym alias-map] + (if-let [ns-string (and (symbol? possible-sym) + (namespace possible-sym))] + (symbol (get alias-map ns-string ns-string) + (name possible-sym)) + possible-sym)) + +(defn- inner-indent [zloc key depth idx alias-map] + (let [top (nth (iterate z/up zloc) depth)] + (if (and (or (indent-matches? key (fully-qualify-symbol (form-symbol top) alias-map)) + (indent-matches? key (remove-namespace (form-symbol top)))) + (or (nil? idx) (index-matches-top-argument? zloc depth idx))) + (let [zup (z/up zloc)] + (+ (margin zup) (indent-width zup)))))) + +(defn- nth-form [zloc n] + (reduce (fn [z f] (if z (f z))) + (z/leftmost zloc) + (repeat n z/right))) + +(defn- first-form-in-line? [zloc] + (and (some? zloc) + (if-let [zloc (zip/left zloc)] + (if (whitespace? zloc) + (recur zloc) + (or (zlinebreak? zloc) (comment? zloc))) + true))) + +(defn- block-indent [zloc key idx alias-map] + (if (or (indent-matches? key (fully-qualify-symbol (form-symbol zloc) alias-map)) + (indent-matches? key (remove-namespace (form-symbol zloc)))) + (let [zloc-after-idx (some-> zloc (nth-form (inc idx)))] + (if (and (or (nil? zloc-after-idx) (first-form-in-line? zloc-after-idx)) + (> (index-of zloc) idx)) + (inner-indent zloc key 0 nil alias-map) + (list-indent zloc))))) + +(def default-indents + (merge (read-resource "cljfmt/indents/clojure.clj") + (read-resource "cljfmt/indents/compojure.clj") + (read-resource "cljfmt/indents/fuzzy.clj"))) + +(defmulti ^:private indenter-fn + (fn [sym alias-map [type & args]] type)) + +(defmethod indenter-fn :inner [sym alias-map [_ depth idx]] + (fn [zloc] (inner-indent zloc sym depth idx alias-map))) + +(defmethod indenter-fn :block [sym alias-map [_ idx]] + (fn [zloc] (block-indent zloc sym idx alias-map))) + +(defn- make-indenter [[key opts] alias-map] + (apply some-fn (map (partial indenter-fn key alias-map) opts))) + +(defn- indent-order [[key _]] + (cond + (and (symbol? key) (namespace key)) (str 0 key) + (symbol? key) (str 1 key) + (pattern? key) (str 2 key))) + +(defn- custom-indent [zloc indents alias-map] + (if (empty? indents) + (list-indent zloc) + (let [indenter (->> (sort-by indent-order indents) + (map #(make-indenter % alias-map)) + (apply some-fn))] + (or (indenter zloc) + (list-indent zloc))))) + +(defn- indent-amount [zloc indents alias-map] + (let [tag (-> zloc z/up z/tag) + gp (-> zloc z/up z/up)] + (cond + (reader-conditional? gp) (coll-indent zloc) + (#{:list :fn} tag) (custom-indent zloc indents alias-map) + (= :meta tag) (indent-amount (z/up zloc) indents alias-map) + :else (coll-indent zloc)))) + +(defn- indent-line [zloc indents alias-map] + (let [width (indent-amount zloc indents alias-map)] + (if (> width 0) + (zip/insert-right zloc (whitespace width)) + zloc))) + +(defn indent + ([form] + (indent form default-indents)) + ([form indents] + (transform form edit-all should-indent? #(indent-line % indents {}))) + ([form indents alias-map] + (transform form edit-all should-indent? #(indent-line % indents alias-map)))) + +(defn reindent + ([form] + (indent (unindent form))) + ([form indents] + (indent (unindent form) indents)) + ([form indents alias-map] + (indent (unindent form) indents alias-map))) + +(defn root? [zloc] + (nil? (zip/up zloc))) + +(defn final? [zloc] + (and (nil? (zip/right zloc)) (root? (zip/up zloc)))) + +(defn- trailing-whitespace? [zloc] + (and (whitespace? zloc) + (or (zlinebreak? (zip/right zloc)) (final? zloc)))) + +(defn remove-trailing-whitespace [form] + (transform form edit-all trailing-whitespace? zip/remove)) + +(defn- top-level-form [zloc] + (->> zloc + (iterate z/up) + (take-while (complement root?)) + last)) + +(def default-line-separator + \newline) + +(defn normalize-newlines [s] + (str/replace s #"\r\n" "\n")) + +(defn replace-newlines [s sep] + (str/replace s #"\n" sep)) + +(defn find-line-separator [s] + (or (re-find #"\r?\n" s) default-line-separator)) + +(defn wrap-normalize-newlines [f] + (fn [s] + (let [sep (find-line-separator s)] + (-> s normalize-newlines f (replace-newlines sep))))) +(defn- append-newline-if-absent [zloc] + (if (or (-> zloc zip/right skip-whitespace skip-comma line-break?) + (z/rightmost? zloc)) + zloc + (zip/insert-right zloc (n/newlines 1)))) + +(defn- map-odd-seq + "Applies f to all oddly-indexed nodes." + [f zloc] + (loop [loc (z/down zloc) + parent zloc] + (if-not (and loc (z/node loc)) + parent + (if-let [v (f loc)] + (recur (z/right (z/right v)) (z/up v)) + (recur (z/right (z/right loc)) parent))))) + +(defn- map-even-seq + "Applies f to all evenly-indexed nodes." + [f zloc] + (loop [loc (z/right (z/down zloc)) + parent zloc] + (if-not (and loc (z/node loc)) + parent + (if-let [v (f loc)] + (recur (z/right (z/right v)) (z/up v)) + (recur (z/right (z/right loc)) parent))))) + +(defn- add-map-newlines [zloc] + (map-even-seq #(cond-> % (complement z/rightmost?) + append-newline-if-absent) zloc)) + +(defn- add-binding-newlines [zloc] + (map-even-seq append-newline-if-absent zloc)) + +(defn- update-in-path [[node path :as loc] k f] + (let [v (get path k)] + (if (seq v) + (with-meta + [node (assoc path k (f v) :changed? true)] + (meta loc)) + loc))) + +(defn- remove-right + [loc] + (update-in-path loc :r next)) + +(defn- *remove-right-while + [zloc p?] + (loop [zloc zloc] + (if-let [rloc (zip/right zloc)] + (if (p? rloc) + (recur (remove-right zloc)) + zloc) + zloc))) + +(defn- align-seq-value [zloc max-length] + (let [key-length (-> zloc z/sexpr str count) + width (- max-length key-length) + zloc (*remove-right-while zloc zwhitespace?)] + (zip/insert-right zloc (whitespace (inc width))))) + +(defn- align-map [zloc] + (let [key-list (-> zloc z/sexpr keys) + max-key-length (apply max (map #(-> % str count) key-list))] + (map-odd-seq #(align-seq-value % max-key-length) zloc))) + +(defn- align-binding [zloc] + (let [vec-sexpr (z/sexpr zloc) + odd-elements (take-nth 2 vec-sexpr) + max-length (apply max (map #(-> % str count) odd-elements))] + (map-odd-seq #(align-seq-value % max-length) zloc))) + +(defn- align-elements [zloc] + (if (z/map? zloc) + (-> zloc align-map add-map-newlines) + (-> zloc align-binding add-binding-newlines))) + +(def ^:private binding-keywords + #{"doseq" "let" "loop" "binding" "with-open" "go-loop" "if-let" "when-some" + "if-some" "for" "with-local-vars" "with-redefs" "when-let"}) + +(defn- binding? [zloc] + (and (z/vector? zloc) + (-> zloc z/sexpr count even?) + (->> zloc + z/left + z/string + (contains? binding-keywords)))) + +(defn- align-binding? [zloc] + (and (binding? zloc) + (-> zloc z/sexpr count (> 2)))) + +(defn- empty-seq? [zloc] + (if (z/map? zloc) + (-> zloc z/sexpr empty?) + false)) + +(defn- align-map? [zloc] + (and (z/map? zloc) + (not (empty-seq? zloc)))) + +(defn- align-elements? [zloc] + (or (align-binding? zloc) + (align-map? zloc))) + +(defn align-collection-elements [form] + (transform form edit-all align-elements? align-elements)) + + +(defn reformat-form + ([form] + (reformat-form form {})) + ([form opts] + (-> form + (cond-> (:remove-consecutive-blank-lines? opts true) + remove-consecutive-blank-lines) + (cond-> (:remove-surrounding-whitespace? opts true) + remove-surrounding-whitespace) + (cond-> (:insert-missing-whitespace? opts true) + insert-missing-whitespace) + (cond-> (:align-associative? opts true) + align-collection-elements) + (cond-> (:indentation? opts true) + (reindent (:indents opts default-indents))) + (cond-> (:remove-trailing-whitespace? opts true) + remove-trailing-whitespace)))) + + +(defn reformat-string + ([form-string] + (reformat-string form-string {})) + ([form-string options] + (let [parsed-form (p/parse-string-all form-string) + alias-map (:alias-map options)] + (-> parsed-form + (reformat-form (cond-> options + alias-map (assoc :alias-map alias-map))) + (n/string))))) + diff --git a/src/cljs-lib/src/pez_rewrite_clj/node.cljs b/src/cljs-lib/src/pez_rewrite_clj/node.cljs new file mode 100644 index 0000000..c6729e3 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node.cljs @@ -0,0 +1,197 @@ +(ns pez-rewrite-clj.node + "Facade for node related namespaces." + (:require [pez-rewrite-clj.node.coercer] + [pez-rewrite-clj.node.protocols :as prot] + [pez-rewrite-clj.node.keyword :as kw-node] + [pez-rewrite-clj.node.seq :as seq-node] + [pez-rewrite-clj.node.whitespace :as ws-node] + [pez-rewrite-clj.node.token :as tok-node] + [pez-rewrite-clj.node.comment :as cmt-node] + [pez-rewrite-clj.node.forms :as fm-node] + [pez-rewrite-clj.node.meta :as mt-node] + [pez-rewrite-clj.node.stringz :as s-node] + [pez-rewrite-clj.node.reader-macro :as rm-node] + [pez-rewrite-clj.node.quote :as q-node] + [pez-rewrite-clj.node.uneval :as ue-node] + [pez-rewrite-clj.node.fn :as f-node])) + + + + + +; ******************************* +; see pez-rewrite-clj.node.protocols +; ******************************* +(def tag + "See [[protocols/tag]]" + prot/tag) +(def sexpr + "See [[protocols/sexpr]]" + prot/sexpr) +(def string + "See [[protocols/string]]" + prot/string) +(def children + "See [[protocols/children]]" + prot/children) +(def child-sexprs + "See [[protocols/sexprs]]" + prot/child-sexprs) +(def replace-children + "See [[protocols/replace-children]]" + prot/replace-children) +(def inner? + "See [[protocols/inner?]]" + prot/inner?) +(def printable-only? + "See [[protocols/printable-only?]]" + prot/printable-only?) +(def coerce + "See [[protocols/coerce]]" + prot/coerce) +(def length + "See [[protocols/length]]" + prot/length) + + +; ******************************* +; see pez-rewrite-clj.node.forms +; ******************************* +(def forms-node + "see [[forms/forms-node]]" + fm-node/forms-node) +(def keyword-node + "see [[keyword/keyword-node]]" + kw-node/keyword-node) + + +; ******************************* +; see pez-rewrite-clj.node.seq +; ******************************* +(def list-node + "See [[seq/list-node]]" + seq-node/list-node) +(def vector-node + "See [[seq/vector-node]]" + seq-node/vector-node) +(def set-node + "See [[seq/set-node]]" + seq-node/set-node) +(def map-node + "See [[seq/map-node]]" + seq-node/map-node) + + +; ******************************* +; see pez-rewrite-clj.node.string +; ******************************* +(def string-node + "See [[stringz/string-node]]" + s-node/string-node) + + + +; ******************************* +; see pez-rewrite-clj.node.comment +; ******************************* +(def comment-node + "See [[comment/comment-node]]" + cmt-node/comment-node) +(def comment? + "See [[comment/comment?]]" + cmt-node/comment?) + + + +; ******************************* +; see pez-rewrite-clj.node.whitespace +; ******************************* +(def whitespace-node + "See [[whitespace/whitespace-node]]" + ws-node/whitespace-node) +(def newline-node + "See [[whitespace/newline-node]]" + ws-node/newline-node) +(def spaces + "See [[whitespace/spaces]]" + ws-node/spaces) +(def newlines + "See [[whitespace/newlines]]" + ws-node/newlines) +(def whitespace? + "See [[whitespace/whitespace?]]" + ws-node/whitespace?) +(def linebreak? + "See [[whitespace/linebreak?]]" + ws-node/linebreak?) + +(defn whitespace-or-comment? + "Check whether the given node represents whitespace or comment." + [node] + (or (whitespace? node) + (comment? node))) + + +; ******************************* +; see pez-rewrite-clj.node.token +; ******************************* +(def token-node + "See [[token/token-node]]" + tok-node/token-node) + + +; ******************************* +; see pez-rewrite-clj.node.reader-macro +; ******************************* +(def var-node + "See [[reader-macro/var-node]]" + rm-node/var-node) +(def eval-node + "See [[reader-macro/eval-node]]" + rm-node/eval-node) +(def reader-macro-node + "See [[reader-macro/reader-macro-node]]" + rm-node/reader-macro-node) +(def deref-node + "See [[reader-macro/deref-node]]" + rm-node/deref-node) + + +; ******************************* +; see pez-rewrite-clj.node.quote +; ******************************* +(def quote-node + "See [[quote/quote-node]]" + q-node/quote-node) +(def syntax-quote-node + "See [[quote/syntax-quote-node]]" + q-node/syntax-quote-node) +(def unquote-node + "See [[quote/unquote-node]]" + q-node/unquote-node) +(def unquote-splicing-node + "See [[quote/unquote-splicing-node]]" + q-node/unquote-splicing-node) + + +; ******************************* +; see pez-rewrite-clj.node.uneval +; ******************************* +(def uneval-node + "See [[uneval/uneval-node]]" + ue-node/uneval-node) + + +; ******************************* +; see pez-rewrite-clj.node.meta +; ******************************* +(def meta-node + "See [[meta/meta-node]]" + mt-node/meta-node) + +; ******************************* +; see pez-rewrite-clj.node.fn +; ******************************* +(def fn-node + "See [[fn/fn-node]]" + f-node/fn-node) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/coercer.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/coercer.cljs new file mode 100644 index 0000000..9f208ad --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/coercer.cljs @@ -0,0 +1,136 @@ +(ns pez-rewrite-clj.node.coercer + (:require [pez-rewrite-clj.node.comment :refer [CommentNode]] + [pez-rewrite-clj.node.forms :refer [FormsNode]] + [pez-rewrite-clj.node.keyword :refer [KeywordNode]] + [pez-rewrite-clj.node.quote :refer [QuoteNode]] + [pez-rewrite-clj.node.stringz :refer [StringNode string-node]] + [pez-rewrite-clj.node.uneval :refer [UnevalNode]] + [pez-rewrite-clj.node.meta :refer [MetaNode meta-node]] + [pez-rewrite-clj.node.fn :refer [FnNode]] + [pez-rewrite-clj.node.protocols :refer [NodeCoerceable coerce]] + [pez-rewrite-clj.node.reader-macro :refer [ReaderNode ReaderMacroNode DerefNode]] + [pez-rewrite-clj.node.seq :refer [SeqNode vector-node list-node set-node map-node]] + [pez-rewrite-clj.node.token :refer [TokenNode token-node]] + [pez-rewrite-clj.node.whitespace :refer [WhitespaceNode NewlineNode whitespace-node space-separated]])) + +;; ## Helpers + +(defn node-with-meta + [n value] + (if (implements? IWithMeta value) + (let [mta (meta value)] + (if (empty? mta) + n + (meta-node (coerce mta) n))) + n)) + + +;; ## Tokens + +(extend-protocol NodeCoerceable + object + (coerce [v] + (node-with-meta + (token-node v) + v))) + +;; Number +(extend-protocol NodeCoerceable + number + (coerce [n] + (node-with-meta + (token-node n) + n))) + +;; Number +(extend-protocol NodeCoerceable + string + (coerce [n] + (node-with-meta + (string-node n) + n))) + + + +;; ## Seqs + +(defn seq-node + [f sq] + (node-with-meta + (->> (map coerce sq) + (space-separated) + (vec) + (f)) + sq)) + +(extend-protocol NodeCoerceable + PersistentVector + (coerce [sq] + (seq-node vector-node sq)) + List + (coerce [sq] + (seq-node list-node sq)) + PersistentHashSet + (coerce [sq] + (seq-node set-node sq))) + + + + +;; ## Maps + +(let [comma (whitespace-node ", ") + space (whitespace-node " ")] + (defn- map->children + [m] + (->> (mapcat + (fn [[k v]] + [(coerce k) space (coerce v) comma]) + m) + (butlast) + (vec)))) + + +(extend-protocol NodeCoerceable + PersistentHashMap + (coerce [m] + (node-with-meta + (map-node (map->children m)) + m))) + + + + +;(seq-node vector-node [1]) + +;; ## Vars + +;; (extend-protocol NodeCoerceable +;; Var +;; (coerce [v] +;; (-> (str v) +;; (subs 2) +;; (symbol) +;; (token-node) +;; (vector) +;; (var-node)))) + +;; ## Existing Nodes + +(extend-protocol NodeCoerceable + CommentNode (coerce [v] v) + FormsNode (coerce [v] v) + FnNode (coerce [v] v) + ;IntNode (coerce [v] v) + KeywordNode (coerce [v] v) + MetaNode (coerce [v] v) + QuoteNode (coerce [v] v) + ReaderNode (coerce [v] v) + ReaderMacroNode (coerce [v] v) + DerefNode (coerce [v] v) + StringNode (coerce [v] v) + ;UnevalNode (coerce [v] v) + NewlineNode (coerce [v] v) + SeqNode (coerce [v] v) + TokenNode (coerce [v] v) + WhitespaceNode (coerce [v] v)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/comment.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/comment.cljs new file mode 100644 index 0000000..607b80d --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/comment.cljs @@ -0,0 +1,36 @@ +(ns pez-rewrite-clj.node.comment + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defrecord CommentNode [s] + node/Node + (tag [_] :comment) + (printable-only? [_] true) + (sexpr [_] + (throw (js/Error. "Unsupported operation"))) + (length [_] + (+ 1 (count s))) + (string [_] + (str ";" s)) + + Object + (toString [this] + (node/string this))) + +;;(node/make-printable! CommentNode) + +;; ## Constructor + +(defn comment-node + "Create node representing an EDN comment." + [s] + (->CommentNode s)) + +(defn comment? + "Check whether a node represents a comment." + [node] + (= (node/tag node) :comment)) + + + diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/fn.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/fn.cljs new file mode 100644 index 0000000..6aadc54 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/fn.cljs @@ -0,0 +1,97 @@ +(ns ^:no-doc pez-rewrite-clj.node.fn + (:require [pez-rewrite-clj.node.protocols :as node] + [clojure.walk :as w])) + +;; ## Conversion + +(defn- construct-fn + "Construct function form." + [syms vararg body] + (list + 'fn* + (vec + (concat + syms + (if vararg + (list '& vararg)))) + body)) + +(defn- sym-index + "Get index based on the substring following the parameter's `%`. + Zero means vararg." + [n] + (cond (= n "&") 0 + (= n "") 1 + (re-matches #"\d+" n) (js/parseInt n) + :else (throw (js/Error. "arg literal must be %, %& or %integer.")))) + +;; TODO: No promises available +(defn- symbol->gensym + "If symbol starting with `%`, convert to respective gensym." + [sym-seq vararg? max-n sym] + (if (symbol? sym) + (let [nm (name sym)] + (if (= (.indexOf nm "%") 0) + (let [i (sym-index (subs nm 1))] +;; (if (and (= i 0) (not (realized? vararg?))) +;; (deliver vararg? true)) + (swap! max-n max i) + (nth sym-seq i)))))) + +;; TODO: No promises available +(defn- fn-walk + "Walk the form and create an expand function form." + [form] + (let [syms (for [i (range) + :let [base (if (= i 0) + "rest__" + (str "p" i "__")) + s (name (gensym base))]] + (symbol (str s "#"))) + vararg? false ;(promise) + max-n (atom 0) + body (w/prewalk + #(or (symbol->gensym syms vararg? max-n %) %) + form)] + (construct-fn + (take @max-n (rest syms)) + nil +;; (if (deref vararg? 0 nil) +;; (first syms)) + body))) + +;; ## Node + +(defrecord FnNode [children] + node/Node + (tag [_] :fn) + (printable-only? [_] + false) + (sexpr [_] + (fn-walk (node/sexprs children))) + (length [_] + (+ 3 (node/sum-lengths children))) + (string [_] + (str "#(" (node/concat-strings children) ")")) + + node/InnerNode + (inner? [_] + true) + (children [_] + children) + (replace-children [this children'] + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;; TODO +;(node/make-printable! FnNode) + +;; ## Constructor + +(defn fn-node + "Create node representing an anonymous function." + [children] + (->FnNode children)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/forms.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/forms.cljs new file mode 100644 index 0000000..deda783 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/forms.cljs @@ -0,0 +1,43 @@ +(ns pez-rewrite-clj.node.forms + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defrecord FormsNode [children] + node/Node + (tag [_] + :forms) + (printable-only? [_] + false) + (sexpr [_] + (let [es (node/sexprs children)] + (if (next es) + (list* 'do es) + (first es)))) + (length [_] + (node/sum-lengths children)) + (string [_] + (node/concat-strings children)) + + node/InnerNode + (inner? [_] + true) + (children [_] + children) + (replace-children [this children'] + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;; TODO: Macro fun ! +;(node/make-printable! FormsNode) + +;; ## Constructor + +(defn forms-node + "Create top-level node wrapping multiple children + (equals an implicit `do` on the top-level)." + [children] + (->FormsNode children)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/keyword.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/keyword.cljs new file mode 100644 index 0000000..3dc047e --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/keyword.cljs @@ -0,0 +1,48 @@ +(ns pez-rewrite-clj.node.keyword + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defrecord KeywordNode [k namespaced?] + node/Node + (tag [_] :token) + (printable-only? [_] false) + (sexpr [_] + (if (and namespaced? + (not (namespace k))) +;; (keyword +;; (name (ns-name *ns*)) +;; (name k)) + (throw (js/Error. "Namespaced keywords not supported !")) + k)) + (length [this] + (let [c (inc (count (name k)))] + (if namespaced? + (inc c) + (if-let [nspace (namespace k)] + (+ 1 c (count nspace)) + c)))) + (string [_] + (let [v (pr-str k)] + (if namespaced? + (str ":" v) + v))) + + Object + (toString [this] + (node/string this))) + + + + +;; TODO +;;(node/make-printable! KeywordNode) + +;; ## Constructor + +(defn keyword-node + "Create node representing a keyword. If `namespaced?` is given as `true` + a keyword à la `::x` or `::ns/x` (i.e. namespaced/aliased) is generated." + [k & [namespaced?]] + {:pre [(keyword? k)]} + (->KeywordNode k namespaced?)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/meta.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/meta.cljs new file mode 100644 index 0000000..d3a47ae --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/meta.cljs @@ -0,0 +1,52 @@ +(ns pez-rewrite-clj.node.meta + (:require [pez-rewrite-clj.node.protocols :as node] + [pez-rewrite-clj.node.whitespace :as ws])) + +;; ## Node + +(defrecord MetaNode [tag prefix children] + node/Node + (tag [_] tag) + (printable-only? [_] false) + (sexpr [_] + (let [[mta data] (node/sexprs children)] + (assert (implements? IWithMeta data) + (str "cannot attach metadata to: " (pr-str data))) + (with-meta data (if (map? mta) mta {mta true})))) + (length [_] + (+ (count prefix) (node/sum-lengths children))) + (string [_] + (str prefix (node/concat-strings children))) + + node/InnerNode + (inner? [_] true) + (children [_] children) + (replace-children [this children'] + (node/assert-sexpr-count children' 2) + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;; TODO +;(node/make-printable! MetaNode) + +;; ## Constructor + +(defn meta-node + "Create node representing a form and its metadata." + ([children] + (node/assert-sexpr-count children 2) + (->MetaNode :meta "^" children)) + ([metadata data] + (meta-node [metadata (ws/spaces 1) data]))) + +(defn raw-meta-node + "Create node representing a form and its metadata using the + `#^` prefix." + ([children] + (node/assert-sexpr-count children 2) + (->MetaNode :meta* "#^" children)) + ([metadata data] + (raw-meta-node [metadata (ws/spaces 1) data]))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/protocols.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/protocols.cljs new file mode 100644 index 0000000..8cbb221 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/protocols.cljs @@ -0,0 +1,105 @@ +(ns pez-rewrite-clj.node.protocols + (:require [clojure.string :as s])) + + + +(defprotocol Node + "Protocol for EDN/Clojure nodes." + (tag [_] + "Keyword representing the type of the node.") + (printable-only? [_] + "Return true if the node cannot be converted to an s-expression + element.") + (sexpr [_] + "Convert node to s-expression.") + (length [_] + "Get number of characters for the string version of this node.") + (string [_] + "Convert node to printable string.")) + + +(extend-protocol Node + object + (tag [_] :unknown) + (printable-only? [_] false) + (sexpr [this] this) + (length [this] (count (string this))) + (string [this] (pr-str this))) + +(defn sexprs + "Given a seq of nodes, convert those that represent s-expressions + to the respective forms." + [nodes] + (->> nodes + (remove printable-only?) + (map sexpr))) + +(defn sum-lengths + "Sum up lengths of the given nodes." + [nodes] + (reduce + (map length nodes))) + +(defn concat-strings + "Convert nodes to strings and concatenate them." + [nodes] + (reduce str (map string nodes))) + + +(defprotocol InnerNode + "Protocol for non-leaf EDN/Clojure nodes." + (inner? [_] + "Check whether the node can contain children.") + (children [_] + "Get child nodes.") + (replace-children [_ children] + "Replace the node's children.")) + +(extend-protocol InnerNode + object + (inner? [_] false) + (children [_] + (throw (js/Error. "UnsupportedOperationException"))) + (replace-children [_ _] + (throw (js/Error. "UnsupportedOperationException")))) + +(defn child-sexprs + "Get all child s-expressions for the given node." + [node] + (if (inner? node) + (sexprs (children node)))) + + +(defprotocol NodeCoerceable + "Protocol for values that can be coerced to nodes." + (coerce [_])) + + +;; TODO: Need to handle format !!!! +;; (defn- node->string +;; [node] +;; (let [n (str (if (printable-only? node) +;; (pr-str (string node)) +;; (string node))) +;; n' (if (re-find #"\n" n) +;; (->> (s/replace n #"\r?\n" "\n ") +;; (format "%n %s%n")) +;; (str " " n))] +;; (format "<%s:%s>" (name (tag node)) n'))) + + +;; (defn write-node +;; [writer node] +;; (str writer (node->string node))) + + +;; ## Helpers + +(defn assert-sexpr-count + [nodes c] + (assert + (= (count (remove printable-only? nodes)) c) + (str "can only contain" c " non-whitespace form(s)."))) + +(defn assert-single-sexpr + [nodes] + (assert-sexpr-count nodes 1)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/quote.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/quote.cljs new file mode 100644 index 0000000..a70c602 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/quote.cljs @@ -0,0 +1,75 @@ +(ns ^:no-doc pez-rewrite-clj.node.quote + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defrecord QuoteNode [tag prefix sym children] + node/Node + (tag [_] tag) + (printable-only? [_] false) + (sexpr [_] + (list sym (first (node/sexprs children)))) + (length [_] + (+ (count prefix) (node/sum-lengths children))) + (string [_] + (str prefix (node/concat-strings children))) + + node/InnerNode + (inner? [_] true) + (children [_] children) + (replace-children [this children'] + (node/assert-single-sexpr children') + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;(node/make-printable! QuoteNode) + +;; ## Constructors + +(defn- ->node + [t prefix sym children] + (node/assert-single-sexpr children) + (->QuoteNode t prefix sym children)) + +(defn quote-node + "Create node representing a quoted form. + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->node + :quote "'" 'quote + children) + (recur [children]))) + +(defn syntax-quote-node + "Create node representing a syntax-quoted form. + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->node + :syntax-quote "`" 'quote + children) + (recur [children]))) + +(defn unquote-node + "Create node representing an unquoted form. (`~...`) + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->node + :unquote "~" 'unquote + children) + (recur [children]))) + +(defn unquote-splicing-node + "Create node representing an unquote-spliced form. (`~@...`) + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->node + :unquote-splicing "~@" 'unquote-splicing + children) + (recur [children]))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/reader_macro.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/reader_macro.cljs new file mode 100644 index 0000000..882b6f8 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/reader_macro.cljs @@ -0,0 +1,134 @@ +(ns ^:no-doc pez-rewrite-clj.node.reader-macro + (:require [pez-rewrite-clj.node.protocols :as node] + [pez-rewrite-clj.node.whitespace :as ws])) + +;; ## Node + +(defrecord ReaderNode [tag prefix suffix + sexpr-fn sexpr-count + children] + node/Node + (tag [_] tag) + (printable-only? [_] + (not sexpr-fn)) + (sexpr [_] + (if sexpr-fn + (sexpr-fn (node/sexprs children)) + (throw (js/Error. "Unsupported operation")))) + (length [_] + (-> (node/sum-lengths children) + (+ 1 (count prefix) (count suffix)))) + (string [_] + (str "#" prefix (node/concat-strings children) suffix)) + + node/InnerNode + (inner? [_] + true) + (children [_] + children) + (replace-children [this children'] + (when sexpr-count + (node/assert-sexpr-count children' sexpr-count)) + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +(defrecord ReaderMacroNode [children] + node/Node + (tag [_] :reader-macro) + (printable-only?[_] false) + (sexpr [this] + (list 'read-string (node/string this))) + (length [_] + (inc (node/sum-lengths children))) + (string [_] + (str "#" (node/concat-strings children))) + + node/InnerNode + (inner? [_] + true) + (children [_] + children) + (replace-children [this children'] + (node/assert-sexpr-count children' 2) + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +(defrecord DerefNode [children] + node/Node + (tag [_] :deref) + (printable-only?[_] false) + (sexpr [this] + (list* 'deref (node/sexprs children))) + (length [_] + (inc (node/sum-lengths children))) + (string [_] + (str "@" (node/concat-strings children))) + + node/InnerNode + (inner? [_] + true) + (children [_] + children) + (replace-children [this children'] + (node/assert-sexpr-count children' 1) + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;; TODO: +;; (node/make-printable! ReaderNode) +;; (node/make-printable! ReaderMacroNode) +;; (node/make-printable! DerefNode) + +;; ## Constructors + +(defn- ->node + [tag prefix suffix sexpr-fn sexpr-count children] + (when sexpr-count + (node/assert-sexpr-count children sexpr-count)) + (->ReaderNode + tag prefix suffix + sexpr-fn sexpr-count + children)) + +(defn var-node + "Create node representing a var. + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->node :var "'" "" #(list* 'var %) 1 children) + (recur [children]))) + +(defn eval-node + "Create node representing an inline evaluation. (`#=...`) + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->node + :eval "=" "" + #(list 'eval (list* 'quote %)) + 1 children) + (recur [children]))) + +(defn reader-macro-node + "Create node representing a reader macro. (`#... ...`)" + ([children] + (->ReaderMacroNode children)) + ([macro-node form-node] + (->ReaderMacroNode [macro-node (ws/spaces 1) form-node]))) + +(defn deref-node + "Create node representing the dereferencing of a form. (`@...`) + Takes either a seq of nodes or a single one." + [children] + (if (sequential? children) + (->DerefNode children) + (->DerefNode [children]))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/seq.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/seq.cljs new file mode 100644 index 0000000..fa46d9c --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/seq.cljs @@ -0,0 +1,65 @@ +(ns pez-rewrite-clj.node.seq + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defn wrap-vec [s] (str "[" s "]")) +(defn wrap-list [s] (str "(" s ")")) +(defn wrap-set [s] (str "#{" s "}")) +(defn wrap-map [s] (str "{" s "}")) + + + +(defrecord SeqNode [tag + wrap-fn + wrap-length + seq-fn + children] + node/Node + (tag [this] + tag) + (printable-only? [_] false) + (sexpr [this] + (seq-fn (node/sexprs children))) + (length [_] + (+ wrap-length (node/sum-lengths children))) + (string [this] + (->> (node/concat-strings children) + wrap-fn)) + + node/InnerNode + (inner? [_] + true) + (children [_] + children) + (replace-children [this children'] + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;; TODO +;(node/make-printable! SeqNode) + +;; ## Constructors + +(defn list-node + "Create a node representing an EDN list." + [children] + (->SeqNode :list wrap-list 2 #(apply list %) children)) + +(defn vector-node + "Create a node representing an EDN vector." + [children] + (->SeqNode :vector wrap-vec 2 vec children)) + +(defn set-node + "Create a node representing an EDN set." + [children] + (->SeqNode :set wrap-set 3 set children)) + +(defn map-node + "Create a node representing an EDN map." + [children] + (->SeqNode :map wrap-map 2 #(apply hash-map %) children)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/stringz.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/stringz.cljs new file mode 100644 index 0000000..6e4929a --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/stringz.cljs @@ -0,0 +1,48 @@ +(ns pez-rewrite-clj.node.stringz + (:require [pez-rewrite-clj.node.protocols :as node] + [cljs.tools.reader :as r] + [clojure.string :as s])) + +;; ## Node + +(defn- wrap-string + [v] + (str "\"" v "\"")) + +(defn- join-lines + [lines] + (s/join "\n" lines)) + +(defrecord StringNode [lines] + node/Node + (tag [_] + (if (next lines) + :multi-line + :token)) + (printable-only? [_] + false) + (sexpr [_] + (join-lines + (map + (comp r/read-string wrap-string) + lines))) + (length [_] + (+ 2 (reduce + (map count lines)))) + (string [_] + (wrap-string (join-lines lines))) + + Object + (toString [this] + (node/string this))) + +;(node/make-printable! StringNode) + +;; ## Constructors + +(defn string-node + "Create node representing a string value. + Takes either a seq of strings or a single one." + [lines] + (if (string? lines) + (->StringNode [lines]) + (->StringNode lines))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/token.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/token.cljs new file mode 100644 index 0000000..723089b --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/token.cljs @@ -0,0 +1,28 @@ +(ns pez-rewrite-clj.node.token + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defrecord TokenNode [value string-value] + node/Node + (tag [_] :token) + (printable-only? [_] false) + (sexpr [_] value) + (length [_] (.-length string-value)) + (string [_] string-value) + + Object + (toString [this] + (node/string this))) + +; TODO +;(node/make-printable! TokenNode) + +;; ## Constructor + +(defn token-node + "Create node for an unspecified EDN token." + ([value] + (token-node value (pr-str value))) + ([value string-value] + (->TokenNode value string-value))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/uneval.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/uneval.cljs new file mode 100644 index 0000000..d5ae51f --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/uneval.cljs @@ -0,0 +1,39 @@ +(ns ^:no-doc pez-rewrite-clj.node.uneval + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Node + +(defrecord UnevalNode [children] + node/Node + (tag [_] :uneval) + (printable-only? [_] true) + (sexpr [_] + (throw (js/Error. "Unsupported operation for unevalnode"))) + (length [_] + (+ 2 (node/sum-lengths children))) + (string [_] + (str "#_" (node/concat-strings children))) + + node/InnerNode + (inner? [_] true) + (children [_] children) + (replace-children [this children'] + (node/assert-single-sexpr children') + (assoc this :children children')) + + Object + (toString [this] + (node/string this))) + +;(node/make-printable! UnevalNode) + +;; ## Constructor + +(defn uneval-node + "Create node representing an EDN uneval `#_` form." + [children] + (if (sequential? children) + (do + (node/assert-single-sexpr children) + (->UnevalNode children)) + (recur [children]))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/node/whitespace.cljs b/src/cljs-lib/src/pez_rewrite_clj/node/whitespace.cljs new file mode 100644 index 0000000..020c55d --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/node/whitespace.cljs @@ -0,0 +1,131 @@ +(ns pez-rewrite-clj.node.whitespace + (:require [pez-rewrite-clj.node.protocols :as node])) + +;; ## Newline Modifiers + +(def ^:dynamic *newline-fn* + "This function is applied to every newline string." + identity) + +(def ^:dynamic *count-fn* + "This function is applied to every newline string and should produce + the eventual character count." + count) + + +;; TODO +;; (defmacro with-newline-fn +;; [f & body] +;; `(binding [*newline-fn* (comp *newline-fn* ~f)] +;; ~@body)) + +;; (defmacro with-count-fn +;; [f & body] +;; `(binding [*count-fn* (comp *count-fn* ~f)] +;; ~@body)) + +;; ## Nodes + +(defrecord WhitespaceNode [whitespace] + node/Node + (tag [_] :whitespace) + (printable-only? [_] true) + (sexpr [_] (throw (js/Error. "Unsupported operation"))) + (length [_] (count whitespace)) + (string [_] whitespace) + + Object + (toString [this] + (node/string this))) + +(defrecord NewlineNode [newlines] + node/Node + (tag [_] :newline) + (printable-only? [_] true) + (sexpr [_] (throw (js/Error. "Unsupported operation"))) + (length [_] (*count-fn* newlines)) + (string [_] (*newline-fn* newlines)) + + Object + (toString [this] + (node/string this))) + + +;; TODO +;; (node/make-printable! WhitespaceNode) +;; (node/make-printable! NewlineNode) + +;; ## Constructors + +(defn whitespace-node + "Create whitespace node." + [s] + (->WhitespaceNode s)) + +(defn newline-node + "Create newline node." + [s] + (->NewlineNode s)) + +(defn- newline? + "Check whether a character represents a linebreak." + [c] + (contains? #{\return \newline} c)) + +(defn whitespace-nodes + "Convert a string of whitespace to whitespace/newline nodes." + [s] + (->> (partition-by newline? s) + (map + (fn [char-seq] + (let [s (apply str char-seq)] + (if (newline? (first char-seq)) + (newline-node s) + (whitespace-node s))))))) + +;; ## Utilities + +(defn spaces + "Create node representing the given number of spaces." + [n] + (whitespace-node (apply str (repeat n \space)))) + +(defn newlines + "Create node representing the given number of newline characters." + [n] + (newline-node (apply str (repeat n \newline)))) + + + +(let [comma (whitespace-node ", ")] + (defn comma-separated + "Interleave the given seq of nodes with `\", \"` nodes." + [nodes] + (butlast (interleave nodes (repeat comma))))) + +(let [nl (newline-node "\n")] + (defn line-separated + "Interleave the given seq of nodes with newline nodes." + [nodes] + (butlast (interleave nodes (repeat nl))))) + +(let [space (whitespace-node " ")] + (defn space-separated + "Interleave the given seq of nodes with `\" \"` nodes." + [nodes] + (butlast (interleave nodes (repeat space))))) + +;; ## Predicates + +(defn whitespace? + "Check whether a node represents whitespace." + [node] + (contains? + #{:whitespace + :newline} + (node/tag node))) + +(defn linebreak? + "Check whether a ndoe represents linebreaks." + [node] + (= (node/tag node) :newline)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/paredit.cljs b/src/cljs-lib/src/pez_rewrite_clj/paredit.cljs new file mode 100644 index 0000000..f5c9b36 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/paredit.cljs @@ -0,0 +1,551 @@ +(ns pez-rewrite-clj.paredit + "This namespace provides zipper operations for performing paredit type of + operations on clojure/clojurescript forms. + + You might find inspirational examples here: http://pub.gajendra.net/src/paredit-refcard.pdf" + (:require [pez-rewrite-clj.zip :as z] + [clojure.zip :as zz] + [pez-rewrite-clj.zip.whitespace :as ws] + [pez-rewrite-clj.zip.utils :as u] + [pez-rewrite-clj.node :as nd] + [pez-rewrite-clj.node.stringz :as sn :refer [StringNode] ] + [clojure.string :as cstring])) + + + + +;;***************************** +;; Helpers +;;***************************** + +(defn- ^{:no-doc true} empty-seq? [zloc] + (and (z/seq? zloc) (not (seq (z/sexpr zloc))))) + +;; helper +(defn ^{:no-doc true} move-n [loc f n] + (if (= 0 n) + loc + (->> loc (iterate f) (take (inc n)) last))) + +(defn- ^{:no-doc true} top + [zloc] + (->> zloc + (iterate z/up) + (take-while identity) + last)) + +;; TODO : not very efficent ... +(defn- ^{:no-doc true} global-find-by-node + [zloc n] + (-> zloc + top + (z/find zz/next #(= (meta (z/node %)) (meta n))))) + + + +(defn- ^{:no-doc true} nodes-by-dir + ([zloc f] (nodes-by-dir zloc f constantly)) + ([zloc f p?] + (->> zloc + (iterate f) + (take-while identity) + (take-while p?) + (map z/node)))) + +(defn- ^{:no-doc true} remove-first-if-ws [nodes] + (when (seq nodes) + (if (nd/whitespace? (first nodes)) + (rest nodes) + nodes))) + + +(defn- ^{:no-doc true} remove-ws-or-comment [zloc] + (if-not (ws/whitespace-or-comment? zloc) + zloc + (recur (zz/remove zloc)))) + + +(defn- ^{:no-doc true} create-seq-node + "Creates a sequence node of given type `t` with node values of `v`" + [t v] + (case t + :list (nd/list-node v) + :vector (nd/vector-node v) + :map (nd/map-node v) + :set (nd/set-node v) + (throw (js/Error. (str "Unsupported wrap type: " t))))) + +(defn- ^{:no-doc true} string-node? [zloc] + (= (some-> zloc z/node type) (type (nd/string-node " ")))) + +;;***************************** +;; Paredit functions +;;***************************** + + + + +(defn kill + "Kill all sibling nodes to the right of the current node + + - [1 2| 3 4] => [1 2|]" + [zloc] + (let [left (zz/left zloc)] + (-> zloc + (u/remove-right-while (constantly true)) + zz/remove + (#(if left + (global-find-by-node % (z/node left)) + %))))) + + + +(defn- ^{:no-doc true} kill-in-string-node [zloc pos] + (if (= (z/string zloc) "\"\"") + (z/remove zloc) + (let [bounds (-> zloc z/node meta) + row-idx (- (:row pos) (:row bounds)) + sub-length (if-not (= (:row pos) (:row bounds)) + (dec (:col pos)) + (- (:col pos) (inc (:col bounds))))] + + (-> (take (inc row-idx) (-> zloc z/node :lines)) + vec + (update-in [row-idx] #(.substring % 0 sub-length)) + (#(z/replace zloc (nd/string-node %))))))) + +(defn- ^{:no-doc true} kill-in-comment-node [zloc pos] + (let [col-bounds (-> zloc z/node meta :col)] + (if (= (:col pos) col-bounds) + (z/remove zloc) + (-> zloc + (z/replace (-> zloc + z/node + :s + (.substring 0 (- (:col pos) col-bounds 1)) + nd/comment-node)) + (#(if (zz/right %) + (zz/insert-right % (nd/newlines 1)) + %)))))) + + + +(defn kill-at-pos + "In string and comment aware kill + + Perform kill for given position `pos` Like [[kill]], but: + + - if inside string kills to end of string and stops there + - If inside comment kills to end of line (not including linebreak!) + + `pos` should provide `{:row :col }` which are relative to the start of the given form the zipper represents + `zloc` must be positioned at a node previous (given depth first) to the node at given pos" + [zloc pos] + (if-let [candidate (z/find-last-by-pos zloc pos)] + (cond + (string-node? candidate) (kill-in-string-node candidate pos) + (ws/comment? candidate) (kill-in-comment-node candidate pos) + (and (empty-seq? candidate) + (> (:col pos) (-> candidate z/node meta :col))) (z/remove candidate) + :else (kill candidate)) + zloc)) + + + +(defn- ^{:no-doc true} find-word-bounds + [v col] + (when (<= col (count v)) + [(->> (seq v) + (take col) + reverse + (take-while #(not (= % \space))) count (- col)) + (->> (seq v) + (drop col) + (take-while #(not (or (= % \space) (= % \newline)))) + count + (+ col))])) + + +(defn- ^{:no-doc true} remove-word-at + [v col] + (when-let [[start end] (find-word-bounds v col)] + (str (.substring v 0 start) + (.substring v end)))) + + + +(defn- ^{:no-doc true} kill-word-in-comment-node [zloc pos] + (let [col-bounds (-> zloc z/node meta :col)] + (-> zloc + (z/replace (-> zloc + z/node + :s + (remove-word-at (- (:col pos) col-bounds)) + nd/comment-node))))) + +(defn- ^{:no-doc true} kill-word-in-string-node [zloc pos] + (let [bounds (-> zloc z/node meta) + row-idx (- (:row pos) (:row bounds)) + col (if (= 0 row-idx) + (- (:col pos) (:col bounds)) + (:col pos))] + (-> zloc + (z/replace (-> zloc + z/node + :lines + (update-in [row-idx] + #(remove-word-at % col)) + nd/string-node))))) + + + +(defn kill-one-at-pos + "In string and comment aware kill for one node/word at given pos + + - `(+ |100 100) => (+ |100)` + - `(for |(bar do)) => (foo)` + - `\"|hello world\" => \"| world\"` + - ` ; |hello world => ; |world`" + [zloc pos] + (if-let [candidate (->> (z/find-last-by-pos zloc pos) + (ws/skip zz/right ws/whitespace?))] + (let [bounds (-> candidate z/node meta) + kill-in-node? (not (and (= (:row pos) (:row bounds)) + (<= (:col pos) (:col bounds))))] + (cond + (and kill-in-node? (string-node? candidate)) (kill-word-in-string-node candidate pos) + (and kill-in-node? (ws/comment? candidate)) (kill-word-in-comment-node candidate pos) + (not (z/leftmost? candidate)) (-> (z/remove candidate) + (global-find-by-node (-> candidate z/left z/node))) + :else (z/remove candidate))) + zloc)) + + +(defn- ^{:no-doc true} find-slurpee-up [zloc f] + (loop [l (z/up zloc) + n 1] + (cond + (nil? l) nil + (not (nil? (f l))) [n (f l)] + (nil? (z/up l)) nil + :else (recur (z/up l) (inc n))))) + +(defn- ^{:no-doc true} find-slurpee [zloc f] + (if (empty-seq? zloc) + [(f zloc) 0] + (some-> zloc (find-slurpee-up f) reverse))) + + + + +(defn slurp-forward + "Pull in next right outer node (if none at first level, tries next etc) into + current S-expression + + - `[1 2 [|3] 4 5] => [1 2 [|3 4] 5]`" + [zloc] + (let [[slurpee-loc n-ups] (find-slurpee zloc z/right)] + (if-not slurpee-loc + zloc + (let [slurper-loc (move-n zloc z/up n-ups) + preserves (->> (-> slurper-loc + zz/right + (nodes-by-dir zz/right #(not (= (z/node slurpee-loc) (z/node %))))) + (filter #(or (nd/linebreak? %) (nd/comment? %))))] + (-> slurper-loc + (u/remove-right-while ws/whitespace-or-comment?) + u/remove-right + ((partial reduce z/append-child) preserves) + (z/append-child (z/node slurpee-loc)) + (#(if (empty-seq? zloc) + (-> % z/down (u/remove-left-while ws/whitespace?)) + (global-find-by-node % (z/node zloc))))))))) + +(defn slurp-forward-fully + "Pull in all right outer-nodes into current S-expression, but only the ones at the same level + as the the first one. + + - `[1 2 [|3] 4 5] => [1 2 [|3 4 5]]`" + [zloc] + (let [curr-slurpee (some-> zloc (find-slurpee z/right) first) + num-slurps (some-> curr-slurpee (nodes-by-dir z/right) count inc)] + + (->> zloc + (iterate slurp-forward) + (take num-slurps) + last))) + + +(defn slurp-backward + "Pull in prev left outer node (if none at first level, tries next etc) into + current S-expression + + - `[1 2 [|3] 4 5] => [1 [2 |3] 4 5]`" + [zloc] + (if-let [[slurpee-loc _] (find-slurpee zloc z/left)] + (let [preserves (->> (-> slurpee-loc + zz/right + (nodes-by-dir zz/right ws/whitespace-or-comment?)) + (filter #(or (nd/linebreak? %) (nd/comment? %))))] + (-> slurpee-loc + (u/remove-left-while ws/whitespace-not-linebreak?) + (#(if (and (z/left slurpee-loc) + (not (ws/linebreak? (zz/left %)))) + (ws/prepend-space %) + %)) + (u/remove-right-while ws/whitespace-or-comment?) + zz/remove + z/next + ((partial reduce z/insert-child) preserves) + (z/insert-child (z/node slurpee-loc)) + (#(if (empty-seq? zloc) + (-> % z/down (u/remove-right-while ws/linebreak?)) + (global-find-by-node % (z/node zloc)))))) + zloc)) + +(defn slurp-backward-fully + "Pull in all left outer-nodes into current S-expression, but only the ones at the same level + as the the first one. + + - `[1 2 [|3] 4 5] => [[1 2 |3] 4 5]`" + [zloc] + (let [curr-slurpee (some-> zloc (find-slurpee z/left) first) + num-slurps (some-> curr-slurpee (nodes-by-dir z/left) count inc)] + + (->> zloc + (iterate slurp-backward) + (take num-slurps) + last))) + + +(defn barf-forward + "Push out the rightmost node of the current S-expression into outer right form + + - `[1 2 [|3 4] 5] => [1 2 [|3] 4 5]`" + [zloc] + (let [barfee-loc (z/rightmost zloc)] + + (if-not (z/up zloc) + zloc + (let [preserves (->> (-> barfee-loc + zz/left + (nodes-by-dir zz/left ws/whitespace-or-comment?)) + (filter #(or (nd/linebreak? %) (nd/comment? %))) + reverse)] + (-> barfee-loc + (u/remove-left-while ws/whitespace-or-comment?) + (u/remove-right-while ws/whitespace?) + u/remove-and-move-up + (z/insert-right (z/node barfee-loc)) + ((partial reduce z/insert-right) preserves) + (#(or (global-find-by-node % (z/node zloc)) + (global-find-by-node % (z/node barfee-loc))))))))) + + +(defn barf-backward + "Push out the leftmost node of the current S-expression into outer left form + + - `[1 2 [3 |4] 5] => [1 2 3 [|4] 5]`" + [zloc] + (let [barfee-loc (z/leftmost zloc)] + (if-not (z/up zloc) + zloc + (let [preserves (->> (-> barfee-loc + zz/right + (nodes-by-dir zz/right ws/whitespace-or-comment?)) + (filter #(or (nd/linebreak? %) (nd/comment? %))))] + (-> barfee-loc + (u/remove-left-while ws/whitespace?) + (u/remove-right-while ws/whitespace-or-comment?) ;; probably insert space when on same line ! + zz/remove + (z/insert-left (z/node barfee-loc)) + ((partial reduce z/insert-left) preserves) + (#(or (global-find-by-node % (z/node zloc)) + (global-find-by-node % (z/node barfee-loc))))))))) + + +(defn wrap-around + "Wrap current node with a given type `t` (:vector, :list, :set, :map :fn) + + - `|123 => [|123] ; given :vector` + - `|[1 [2]] => [|[1 [2]]]`" + [zloc t] + (-> zloc + (z/insert-left (create-seq-node t nil)) + z/left + (u/remove-right-while ws/whitespace?) + u/remove-right + (zz/append-child (z/node zloc)) + z/down)) + +(defn wrap-fully-forward-slurp + "Create a new seq node of type `t` left of `zloc` then slurp fully into the new node + + - `[1 |2 3 4] => [1 [|2 3 4]]`" + [zloc t] + (-> zloc + (z/insert-left (create-seq-node t nil)) + z/left + slurp-forward-fully)) + +(def splice + "See pez-rewrite-clj.zip/splice" + z/splice) + + +(defn- ^{:no-doc true} splice-killing + [zloc f] + (if-not (z/up zloc) + zloc + (-> zloc + (f (constantly true)) + z/up + splice + (global-find-by-node (z/node zloc))))) + +(defn splice-killing-backward + "Remove left siblings of current given node in S-Expression and unwrap remaining into enclosing S-expression + + - `(foo (let ((x 5)) |(sqrt n)) bar) => (foo (sqrt n) bar)`" + [zloc] + (splice-killing zloc u/remove-left-while)) + +(defn splice-killing-forward + "Remove current given node and its right siblings in S-Expression and unwrap remaining into enclosing S-expression + + - `(a (b c |d e) f) => (a b |c f)`" + [zloc] + (if (and (z/up zloc) (not (z/leftmost? zloc))) + (splice-killing (z/left zloc) u/remove-right-while) + (if (z/up zloc) + (-> zloc z/up z/remove) + zloc))) + + +(defn split + "Split current s-sexpression in two at given node `zloc` + + - `[1 2 |3 4 5] => [1 2 3] [4 5]`" + [zloc] + (let [parent-loc (z/up zloc)] + (if-not parent-loc + zloc + (let [t (z/tag parent-loc) + lefts (reverse (remove-first-if-ws (rest (nodes-by-dir (z/right zloc) zz/left)))) + rights (remove-first-if-ws (nodes-by-dir (z/right zloc) zz/right))] + + (if-not (and (seq lefts) (seq rights)) + zloc + (-> parent-loc + (z/insert-left (create-seq-node t lefts)) + (z/insert-left (create-seq-node t rights)) + z/remove + (#(or (global-find-by-node % (z/node zloc)) + (global-find-by-node % (last lefts)))))))))) + + +(defn- ^{:no-doc true} split-string [zloc pos] + (let [bounds (-> zloc z/node meta) + row-idx (- (:row pos) (:row bounds)) + lines (-> zloc z/node :lines) + split-col (if-not (= (:row pos) (:row bounds)) + (dec (:col pos)) + (- (:col pos) (inc (:col bounds))))] + (-> zloc + (z/replace (nd/string-node + (-> (take (inc row-idx) lines) + vec + (update-in [row-idx] #(.substring % 0 split-col))))) + (z/insert-right (nd/string-node + (-> (drop row-idx lines) + vec + (update-in [0] #(.substring % split-col)))))))) + + +(defn split-at-pos + "In string aware split + + Perform split at given position `pos` Like split, but: + + - if inside string splits string into two strings + + `pos` should provide `{:row :col }` which are relative to the start of the given form the zipper represents + `zloc` must be positioned at a node previous (given depth first) to the node at given pos" + [zloc pos] + (if-let [candidate (z/find-last-by-pos zloc pos)] + (if (string-node? candidate) + (split-string candidate pos) + (split candidate)) + zloc)) + +(defn- ^{:no-doc true} join-seqs [left right] + (let [lefts (-> left z/node nd/children) + ws-nodes (-> (zz/right left) (nodes-by-dir zz/right ws/whitespace-or-comment?)) + rights (-> right z/node nd/children)] + + (-> right + zz/remove + remove-ws-or-comment + z/up + (z/insert-left (create-seq-node :vector + (concat lefts + ws-nodes + rights))) + z/remove + (global-find-by-node (first rights))))) + + +(defn- ^{:no-doc true} join-strings [left right] + (-> right + zz/remove + remove-ws-or-comment + (z/replace (nd/string-node (str (-> left z/node nd/sexpr) + (-> right z/node nd/sexpr)))))) + +(defn join + "Join S-expression to the left and right of current loc. Also works for strings. + + - `[[1 2] |[3 4]] => [[1 2 3 4]]` + - `[\"Hello \" | \"World\"] => [\"Hello World\"]" + [zloc] + (let [left (some-> zloc z/left) + right (if (some-> zloc z/node nd/whitespace?) (z/right zloc) zloc)] + + + (if-not (and left right) + zloc + (cond + (and (z/seq? left) (z/seq? right)) (join-seqs left right) + (and (string-node? left) (string-node? right)) (join-strings left right) + :else zloc)))) + + +(defn raise + "Delete siblings and raise node at zloc one level up + + - `[1 [2 |3 4]] => [1 |3]`" + [zloc] + (if-let [containing (z/up zloc)] + (-> containing + (z/replace (z/node zloc))) + zloc)) + + +(defn move-to-prev + "Move node at current location to the position of previous location given a depth first traversal + + - `(+ 1 (+ 2 |3) 4) => (+ 1 (+ |3 2) 4)` + - `(+ 1 (+ 2 3) |4) => (+ 1 (+ 2 3 |4))` + + returns zloc after move or given zloc if a move isn't possible" + [zloc] + (let [n (z/node zloc) + p (some-> zloc z/left z/node) + ins-fn (if (or (nil? p) (= (-> zloc z/remove z/node) p)) + #(-> % (z/insert-left n) z/left) + #(-> % (z/insert-right n) z/right))] + (if-not (-> zloc z/remove z/prev) + zloc + (-> zloc + z/remove + ins-fn)))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/parser.cljs b/src/cljs-lib/src/pez_rewrite_clj/parser.cljs new file mode 100644 index 0000000..1767c65 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/parser.cljs @@ -0,0 +1,35 @@ +(ns pez-rewrite-clj.parser + (:require [pez-rewrite-clj.parser.core :as p] + [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.reader :as r])) + +;; ## Parser Core + +(defn parse + "Parse next form from the given reader." + [^not-native reader] + (p/parse-next reader)) + +(defn parse-all + "Parse all forms from the given reader." + [^not-native reader] + (let [nodes (->> (repeatedly #(parse reader)) + (take-while identity) + (doall))] + (with-meta + (node/forms-node nodes) + (meta (first nodes))))) + +;; ## Specialized Parsers + +(defn parse-string + "Parse first form in the given string." + [s] + (parse (r/indexing-push-back-reader s))) + +(defn parse-string-all + "Parse all forms in the given string." + [s] + (parse-all (r/indexing-push-back-reader s))) + + diff --git a/src/cljs-lib/src/pez_rewrite_clj/parser/core.cljs b/src/cljs-lib/src/pez_rewrite_clj/parser/core.cljs new file mode 100644 index 0000000..67db058 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/parser/core.cljs @@ -0,0 +1,172 @@ +(ns pez-rewrite-clj.parser.core + (:require [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.reader :as reader] + [pez-rewrite-clj.parser.keyword :refer [parse-keyword]] + [pez-rewrite-clj.parser.string :refer [parse-string parse-regex]] + [pez-rewrite-clj.parser.token :refer [parse-token]] + [pez-rewrite-clj.parser.whitespace :refer [parse-whitespace]] + [cljs.tools.reader.reader-types :refer [peek-char]])) + +;; ## Base Parser + +(def ^:dynamic ^:private *delimiter* + nil) + + +(declare parse-next) + + +(defn- parse-delim + [^not-native reader delimiter] + (reader/ignore reader) + (->> #(binding [*delimiter* delimiter] + (parse-next %)) + (reader/read-repeatedly reader))) + +(defn- parse-printables + [^not-native reader node-tag n & [ignore?]] + (when ignore? + (reader/ignore reader)) + (reader/read-n + reader + node-tag + parse-next + (complement node/printable-only?) + n)) + + +(defn- parse-meta + [^not-native reader] + (reader/ignore reader) + (node/meta-node (parse-printables reader :meta 2))) + + +(defn- parse-eof + [^not-native reader] + (when *delimiter* + (reader/throw-reader reader "Unexpected EOF."))) + +;; ### Seqs + +(defn- parse-list + [^not-native reader] + (node/list-node (parse-delim reader \)))) + +(defn- parse-vector + [^not-native reader] + (node/vector-node (parse-delim reader \]))) + +(defn- parse-map + [^not-native reader] + (node/map-node (parse-delim reader \}))) + + +;; ### Reader Specialities + + +(defn- parse-conditional [reader] + ;; we need to examine the next character, so consume one (known \?) + (reader/next reader) + ;; we will always have a reader-macro-node as the result + (node/reader-macro-node + (let [read1 (fn [] (parse-printables reader :reader-macro 1))] + (cons (case (reader/peek reader) + ;; the easy case, just emit a token + \( (node/token-node (symbol "?")) + + ;; the harder case, match \@, consume it and emit the token + \@ (do (reader/next reader) + (node/token-node (symbol "?@"))) + + ;; otherwise no idea what we're reading but its \? prefixed + (do (reader/unread reader \?) + (first (read1)))) + (read1))))) + + + +(defn- parse-sharp + [^not-native reader] + (reader/ignore reader) + (if (reader/whitespace? (peek-char reader)) + (node/comment-node (reader/read-include-linebreak reader)) + (case (peek-char reader) + nil (reader/throw-reader reader "Unexpected EOF.") + \{ (node/set-node (parse-delim reader \})) + \( (node/fn-node (parse-delim reader \))) + \" (parse-regex reader) + \^ (node/meta-node (parse-printables reader :meta 2 true)) + \' (node/var-node (parse-printables reader :var 1 true)) + \= (node/eval-node (parse-printables reader :eval 1 true)) + \_ (node/uneval-node (parse-printables reader :uneval 1 true)) + \? (parse-conditional reader) + (node/reader-macro-node (parse-printables reader :reader-macro 2))))) + + + + +(defn- parse-unmatched + [^not-native reader] + (reader/throw-reader + reader + "Unmatched delimiter: %s" + (peek-char reader))) + + +(defn- parse-deref + [^not-native reader] + (node/deref-node (parse-printables reader :deref 1 true))) + +;; ## Quotes + +(defn- parse-quote + [^not-native reader] + (node/quote-node (parse-printables reader :quote 1 true))) + +(defn- parse-syntax-quote + [^not-native reader] + (node/syntax-quote-node (parse-printables reader :syntax-quote 1 true))) + +(defn- parse-unquote + [^not-native reader] + (reader/ignore reader) + (let [c (peek-char reader)] + (if (= c \@) + (node/unquote-splicing-node + (parse-printables reader :unquote 1 true)) + (node/unquote-node + (parse-printables reader :unquote 1))))) + +(defn- parse-comment + [^not-native reader] + (reader/ignore reader) + (node/comment-node (reader/read-include-linebreak reader))) + + + +(defn- dispatch + [c] + (cond (nil? c) parse-eof + (identical? c *delimiter*) reader/ignore + (reader/whitespace? c) parse-whitespace + (identical? c \^) parse-meta + (identical? c \#) parse-sharp + (identical? c \() parse-list + (identical? c \[) parse-vector + (identical? c \{) parse-map + (identical? c \}) parse-unmatched + (identical? c \]) parse-unmatched + (identical? c \)) parse-unmatched + (identical? c \~) parse-unquote + (identical? c \') parse-quote + (identical? c \`) parse-syntax-quote + ;; (identical? c \;) parse-comment + (identical? c \@) parse-deref + (identical? c \") parse-string + (identical? c \:) parse-keyword + :else parse-token)) + + +(defn parse-next + [^not-native rdr] + (reader/read-with-meta rdr (dispatch (peek-char rdr)))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/parser/keyword.cljs b/src/cljs-lib/src/pez_rewrite_clj/parser/keyword.cljs new file mode 100644 index 0000000..76c56ac --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/parser/keyword.cljs @@ -0,0 +1,17 @@ +(ns pez-rewrite-clj.parser.keyword + (:require [pez-rewrite-clj.node :as node] + [cljs.tools.reader.reader-types] + [pez-rewrite-clj.reader :as r])) + +(defn parse-keyword + [^not-native reader] + (r/read-char reader) + (if-let [c (r/peek-char reader)] + (if (identical? c \:) + (node/keyword-node + (r/read-keyword reader ":") + true) + (do + (r/unread reader \:) + (node/keyword-node (r/read-keyword reader ":")))) + (r/throw-reader reader "unexpected EOF while reading keyword."))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/parser/string.cljs b/src/cljs-lib/src/pez_rewrite_clj/parser/string.cljs new file mode 100644 index 0000000..417fcd2 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/parser/string.cljs @@ -0,0 +1,41 @@ +(ns pez-rewrite-clj.parser.string + (:require [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.reader :as r] + [goog.string :as gstring] + [clojure.string :as string])) + +(defn- flush-into + "Flush buffer and add string to the given vector." + [lines buf] + (let [s (.toString buf)] + (.set buf "") + (conj lines s))) + +(defn- read-string-data + [^not-native reader] + (r/ignore reader) + (let [buf (gstring/StringBuffer.)] + (loop [escape? false + lines []] + (if-let [c (r/read-char reader)] + (cond (and (not escape?) (identical? c \")) + (flush-into lines buf) + + (identical? c \newline) + (recur escape? (flush-into lines buf)) + + :else + (do + (.append buf c) + (recur (and (not escape?) (identical? c \\)) lines))) + (r/throw-reader reader "Unexpected EOF while reading string."))))) + +(defn parse-string + [^not-native reader] + (node/string-node (read-string-data reader))) + +(defn parse-regex + [^not-native reader] + (let [lines (read-string-data reader) + regex (string/join "\n" lines)] + (node/token-node (re-pattern regex) (str "#\"" regex "\"")))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/parser/token.cljs b/src/cljs-lib/src/pez_rewrite_clj/parser/token.cljs new file mode 100644 index 0000000..1ea35c5 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/parser/token.cljs @@ -0,0 +1,65 @@ +(ns pez-rewrite-clj.parser.token + (:require [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.reader :as r] + [goog.string :as gstring])) + + +(defn- join-2 [a b] + (-> a gstring/StringBuffer. (.append b) .toString)) + +(defn- ^boolean allowed-default? [c] + false) + +(defn- ^boolean allowed-suffix? [c] + (or (identical? c \') + (identical? c \:))) + + + +(defn- read-to-boundary + [^not-native reader allowed?] + (r/read-until + reader + #(and (not (allowed? %)) + (r/whitespace-or-boundary? %)))) + + + + +(defn- read-to-char-boundary + [^not-native reader] + (let [c (r/read-char reader)] + (join-2 c (if (not (identical? c \\)) + (read-to-boundary reader allowed-default?) + "")))) + + + +(defn- symbol-node + "Symbols allow for certain boundary characters that have + to be handled explicitly." + [^not-native reader value value-string] + (let [suffix (read-to-boundary + reader + allowed-suffix?)] + (if (empty? suffix) + (node/token-node value value-string) + (let [s (join-2 value-string suffix)] + (node/token-node + (r/read-string s) + s))))) + + + + +(defn parse-token + "Parse a single token." + [^not-native reader] + (let [first-char (r/read-char reader) + s (join-2 first-char (if (identical? first-char \\) + (read-to-char-boundary reader) + (read-to-boundary reader allowed-default?))) + v (r/read-string s)] + (if (symbol? v) + (symbol-node reader v s) + (node/token-node v s)))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/parser/whitespace.cljs b/src/cljs-lib/src/pez_rewrite_clj/parser/whitespace.cljs new file mode 100644 index 0000000..fb4abe2 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/parser/whitespace.cljs @@ -0,0 +1,13 @@ +(ns pez-rewrite-clj.parser.whitespace + (:require [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.reader :as r])) + +(defn parse-whitespace + "Parse as much whitespace as possible. The created node can either contain + only linebreaks or only space/tabs." + [^not-native reader] + (if (r/linebreak? (r/peek-char reader)) + (node/newline-node + (r/read-while reader r/linebreak?)) + (node/whitespace-node + (r/read-while reader r/space?)))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/reader.cljs b/src/cljs-lib/src/pez_rewrite_clj/reader.cljs new file mode 100644 index 0000000..cb4dde4 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/reader.cljs @@ -0,0 +1,201 @@ +(ns pez-rewrite-clj.reader + (:refer-clojure :exclude [peek next]) + (:require [cljs.tools.reader :as r] + [cljs.tools.reader.reader-types :as reader-types] + [cljs.tools.reader.impl.commons :refer [parse-symbol]] + [goog.string :as gstring] + [pez-rewrite-clj.node.protocols :as nd])) + +(def read-char reader-types/read-char) +(def get-column-number reader-types/get-column-number) +(def get-line-number reader-types/get-line-number) +(def peek-char reader-types/peek-char) +(def indexing-push-back-reader reader-types/indexing-push-back-reader) +(def unread reader-types/unread) +(def read-string r/read-string) + +;; TODO: try to get goog.string.format up and running ! +(defn throw-reader + "Throw reader exception, including line/column." + [^not-native reader fmt & data] + (let [c (get-column-number reader) + l (get-line-number reader)] + (throw + (js/Error. + (str data fmt + " [at line " l ", column " c "]"))))) + + +(defn boundary? + "Check whether a given char is a token boundary." + [c] + (< -1 (.indexOf #js [\" \: \; \' \@ \^ \` \~ + \( \) \[ \] \{ \} \\ nil] c))) + +(defn ^boolean whitespace? + "Checks whether a given character is whitespace" + [ch] + ;(or (gstring/isBreakingWhitespace ch) (identical? \, ch)) + (< -1 (.indexOf #js [\return \newline \tab \space ","] ch))) + +(defn ^boolean linebreak? + "Checks whether the character is a newline" + [c] + (< -1 (.indexOf #js [\return \newline] c))) + +(defn ^boolean space? + "Checks whether the character is a space" + [c] + (< -1 (.indexOf #js [\tab \space ","] c))) + +(defn ^boolean whitespace-or-boundary? + [c] + (or (whitespace? c) (boundary? c))) + +(def buf (gstring/StringBuffer. "")) + +(defn read-while + "Read while the chars fulfill the given condition. Ignores + the unmatching char." + ([^not-native reader p?] + (read-while reader p? (not (p? nil)))) + + ([^not-native reader p? eof?] + (.clear buf) + (loop [] + (if-let [c (read-char reader)] + (if (p? c) + (do + (.append buf c) + (recur)) + (do + (unread reader c) + (.toString buf))) + (if eof? + (.toString buf) + (throw-reader reader "Unexpected EOF.")))))) + +(defn read-until + "Read until a char fulfills the given condition. Ignores the + matching char." + [^not-native reader p?] + (read-while + reader + (complement p?) + (p? nil))) + +(defn read-include-linebreak + "Read until linebreak and include it." + [^not-native reader] + (str + (read-until + reader + #(or (nil? %) (linebreak? %))) + (read-char reader))) + +(defn string->edn + "Convert string to EDN value." + [s] + (read-string s)) + +(defn ignore + "Ignore the next character." + [^not-native reader] + (read-char reader) + nil) + + +(defn next + "Read next char." + [^not-native reader] + (read-char reader)) + +(defn peek + "Peek next char." + [^not-native reader] + (peek-char reader)) + + + +(defn read-with-meta + "Use the given function to read value, then attach row/col metadata." + [^not-native reader read-fn] + (let [row (get-line-number reader) + col (get-column-number reader) + ^not-native entry (read-fn reader)] + (when entry + (let [end-row (get-line-number reader) + end-col (get-column-number reader) + end-col (if (= 0 end-col) + (+ col (.-length (nd/string entry))) + end-col)] ; TODO: Figure out why numbers are sometimes whacky + (if (= 0 col) ; why oh why + entry + (-with-meta + entry + {:row row + :col col + :end-row end-row + :end-col end-col})))))) + +(defn read-repeatedly + "Call the given function on the given reader until it returns + a non-truthy value." + [^not-native reader read-fn] + (->> (repeatedly #(read-fn reader)) + (take-while identity) + (doall))) + + +(defn read-n + "Call the given function on the given reader until `n` values matching `p?` have been + collected." + [^not-native reader node-tag read-fn p? n] + {:pre [(pos? n)]} + (loop [c 0 + vs []] + (if (< c n) + (if-let [v (read-fn reader)] + (recur + (if (p? v) (inc c) c) + (conj vs v)) + (throw-reader + reader + "%s node expects %d value%s." + node-tag + n + (if (= n 1) "" "s"))) + vs))) + +(defn- re-matches* + [re s] + (let [matches (.exec re s)] + (when (and (not (nil? matches)) + (identical? (aget matches 0) s)) + (if (== (alength matches) 1) + (aget matches 0) + matches)))) + +(defn read-keyword + [^not-native reader initch] + (let [tok (#'cljs.tools.reader/read-token reader :keyword (read-char reader)) + a (re-matches* (re-pattern "^[:]?([^0-9/].*/)?([^0-9/][^/]*)$") tok) + token (aget a 0) + ns (aget a 1) + name (aget a 2)] + (if (or (and (not (undefined? ns)) + (identical? (. ns (substring (- (.-length ns) 2) (.-length ns))) ":/")) + (identical? (aget name (dec (.-length name))) ":") + (not (== (.indexOf token "::" 1) -1))) + (cljs.tools.reader.impl.errors/reader-error reader + "Invalid token: " + token) + (if (and (not (nil? ns)) (> (.-length ns) 0)) + (keyword (.substring ns 0 (.indexOf ns "/")) name) + (keyword (.substring token 1)))))) + +;; (let [form-rdr (r/indexing-push-back-reader "(+ 1 1)")] +;; (read-include-linebreak form-rdr)) + + +;(re-matches* (re-pattern "^[:]?([^0-9/].*/)?([^0-9/][^/]*)$") ":%dill.*") diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip.cljs new file mode 100644 index 0000000..d4aabc0 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip.cljs @@ -0,0 +1,205 @@ +(ns pez-rewrite-clj.zip + "Client facing facade for zipper functions" + (:refer-clojure :exclude [next find replace remove + seq? map? vector? list? set? + print map get assoc]) + (:require [pez-rewrite-clj.zip.base :as base] + [pez-rewrite-clj.parser :as p] + [pez-rewrite-clj.zip.move :as m] + [pez-rewrite-clj.zip.findz :as f] + [pez-rewrite-clj.zip.editz :as ed] + [pez-rewrite-clj.zip.insert :as ins] + [pez-rewrite-clj.zip.removez :as rm] + [pez-rewrite-clj.zip.seqz :as sz] + [clojure.zip :as z])) + + + +(def node + "Function reference to clojure.zip/node" + z/node) +(def root + "Function reference to clojure.zip/root" + z/root) + + +(def of-string + "See [[base/of-string]]" + base/of-string) +(def root-string + "See [[base/root-string]]" + base/root-string) +(def string + "See [[base/string]]" + base/string) +(def tag + "See [[base/tag]]" + base/tag) +(def sexpr + "See [[base/sexpr]]" + base/sexpr) + + + + +;; ********************************** +;; Originally in pez-rewrite-clj.zip.move +;; ********************************** +(def right + "See [[move/right]]" + m/right) +(def left + "See [[move/left]]" + m/left) +(def down + "See [[move/down]]" + m/down) +(def up + "See [[move/up]]" + m/up) +(def next + "See [[move/next]]" + m/next) +(def end? + "See [[move/end?]]" + m/end?) +(def rightmost? + "See [[move/rightmost?]]" + m/rightmost?) +(def leftmost? + "See [[move/leftmost?]]" + m/leftmost?) +(def prev + "See [[move/prev]]" + m/prev) +(def leftmost + "See [[move/leftmost]]" + m/leftmost) +(def rightmost + "See [[move/rightmost]]" + m/rightmost) + + + +;; ********************************** +;; Originally in pez-rewrite-clj.zip.findz +;; ********************************** +(def find + "See [[findz/find]]" + f/find) +(def find-last-by-pos + "See [[findz/find-last-by-pos]]" + f/find-last-by-pos) +(def find-depth-first + "See [[findz/find-depth-first]]" + f/find-depth-first) +(def find-next + "See [[findz/find-next]]" + f/find-next) +(def find-next-depth-first + "See [[findz/find-next-depth-first]]" + f/find-next-depth-first) +(def find-tag + "See [[findz/find-tag]]" + f/find-tag) +(def find-next-tag + "See [[findz/find-next-tag]]" + f/find-next-tag) +(def find-tag-by-pos + "See [[findz/tag-by-pos]]" + f/find-tag-by-pos) +(def find-token + "See [[findz/find-token]]" + f/find-token) +(def find-next-token + "See [[findz/find-next-token]]" + f/find-next-token) +(def find-value + "See [[findz/find-value]]" + f/find-value) +(def find-next-value + "See [[findz/find-next-value]]" + f/find-next-value) + + + +;; ********************************** +;; Originally in pez-rewrite-clj.zip.editz +;; ********************************** +(def replace + "See [[editz/replace]]" + ed/replace) +(def edit + "See [[editz/edit]]" + ed/edit) +(def splice + "See [[editz/splice]]" + ed/splice) +(def prefix + "See [[editz/prefix]]" + ed/prefix) +(def suffix + "See [[editz/suffix]]" + ed/suffix) + +;; ********************************** +;; Originally in pez-rewrite-clj.zip.removez +;; ********************************** +(def remove + "See [[removez/remove]]" + rm/remove) +(def remove-preserve-newline + "See [[removez/remove-preserve-newline]]" + rm/remove-preserve-newline) + + +;; ********************************** +;; Originally in pez-rewrite-clj.zip.insert +;; ********************************** +(def insert-right + "See [[insert/insert-right]]" + ins/insert-right) +(def insert-left + "See [[insert/insert-left]]" + ins/insert-left) +(def insert-child + "See [[insert/insert-child]]" + ins/insert-child) +(def append-child + "See [[insert/append-child]]" + ins/append-child) + + +;; ********************************** +;; Originally in pez-rewrite-clj.zip.seqz +;; ********************************** +(def seq? + "See [[seqz/seq?]]" + sz/seq?) +(def list? + "See [[seqz/list?]]" + sz/list?) +(def vector? + "See [[seqz/vector?]]" + sz/vector?) +(def set? + "See [[seqz/set?]]" + sz/set?) +(def map? + "See [[seqz/map?]]" + sz/map?) +(def map-vals + "See [[seqz/map-vals]]" + sz/map-vals) +(def map-keys + "See [[seqz/map-keys]]" + sz/map-keys) +(def map + "See [[seqz/map]]" + sz/map) +(def get + "See [[seqz/get]]" + sz/get) +(def assoc + "See [[seqz/assoc]]" + sz/assoc) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/base.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/base.cljs new file mode 100644 index 0000000..a2208f5 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/base.cljs @@ -0,0 +1,90 @@ +(ns pez-rewrite-clj.zip.base + (:refer-clojure :exclude [print]) + (:require [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.parser :as p] + [pez-rewrite-clj.zip.whitespace :as ws] + [clojure.zip :as z])) + +;; ## Zipper + +(defn edn* + "Create zipper over the given Clojure/EDN node." + [node] + (z/zipper + node/inner? + (comp seq node/children) + node/replace-children + node)) + +(defn edn + "Create zipper over the given Clojure/EDN node and move + to the first non-whitespace/non-comment child." + [node] + (if (= (node/tag node) :forms) + (let [top (edn* node)] + (or (-> top z/down ws/skip-whitespace) + top)) + (recur (node/forms-node [node])))) + +;; ## Inspection + +(defn tag + "Get tag of node at the current zipper location." + [zloc] + (some-> zloc z/node node/tag)) + +(defn sexpr + "Get sexpr represented by the given node." + [zloc] + (some-> zloc z/node node/sexpr)) + +(defn child-sexprs + "Get children as s-expressions." + [zloc] + (some-> zloc z/node node/child-sexprs)) + +(defn length + "Get length of printable string for the given zipper location." + [zloc] + (or (some-> zloc z/node node/length) 0)) + + +;; ## Read + +(defn of-string + "Create zipper from String." + [s] + (some-> s p/parse-string-all edn)) + + +;; ## Write + +(defn string + "Create string representing the current zipper location." + [zloc] + (some-> zloc z/node node/string)) + +(defn root-string + "Create string representing the zipped-up zipper." + [zloc] + (some-> zloc z/root node/string)) + +;; (defn- print! +;; [s writer] +;; (if writer +;; (.write ^java.io.Writer writer s) +;; (recur s *out*))) + +;; (defn print +;; "Print current zipper location." +;; [zloc & [writer]] +;; (some-> zloc +;; string +;; (print! writer))) + +;; (defn print-root +;; "Zip up and print root node." +;; [zloc & [writer]] +;; (some-> zloc +;; root-string +;; (print! writer))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/editz.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/editz.cljs new file mode 100644 index 0000000..08775dc --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/editz.cljs @@ -0,0 +1,95 @@ +(ns pez-rewrite-clj.zip.editz + (:refer-clojure :exclude [replace]) + (:require [pez-rewrite-clj.zip.base :as base] + [pez-rewrite-clj.zip.move :as m] + [pez-rewrite-clj.zip.removez :as r] + [pez-rewrite-clj.zip.utils :as u] + [pez-rewrite-clj.zip.whitespace :as ws] + [pez-rewrite-clj.node :as n] + [clojure.zip :as z])) + +;; ## In-Place Modification + +(defn replace + "Replace the node at the given location with one representing + the given value. (The value will be coerced to a node if + possible.)" + [zloc value] + (z/replace zloc (n/coerce value))) + +(defn- edit-node + "Create s-expression from node, apply the function and create + node from the result." + [node f] + (-> (n/sexpr node) + (f) + (n/coerce))) + +(defn edit + "Apply the given function to the s-expression at the given + location, using its result to replace the node there. (The + result will be coerced to a node if possible.)" + [zloc f & args] + (z/edit zloc edit-node #(apply f % args))) + +;; ## Splice + + + +(defn splice + "Splice the given node, i.e. merge its children into the current one + (akin to Clojure's `unquote-splicing` macro: `~@...`). + - if the node is not one that can have children, no modification will + be performed. + - if the node has no or only whitespace children, it will be removed. + - otherwise, splicing will be performed, moving the zipper to the first + non-whitespace child afterwards. + " + [zloc] + (if (z/branch? zloc) + (if-let [children (->> (z/children zloc) + (drop-while n/whitespace?) + (reverse) + (drop-while n/whitespace?) + (seq))] + (let [loc (->> (reduce z/insert-right zloc children) + (u/remove-and-move-right))] + (or (ws/skip-whitespace loc) loc)) + (r/remove zloc)) + zloc)) + +;; ## Prefix/Suffix + +(defn- edit-token + [zloc str-fn] + (let [e (base/sexpr zloc) + e' (cond (string? e) (str-fn e) + (keyword? e) (keyword (namespace e) (str-fn (name e))) + (symbol? e) (symbol (namespace e) (str-fn (name e))))] + (z/replace zloc (n/token-node e')))) + +(defn- edit-multi-line + [zloc line-fn] + (let [n (-> (z/node zloc) + (update-in [:lines] (comp line-fn vec)))] + (z/replace zloc n))) + +(defn prefix + [zloc s] + (case (base/tag zloc) + :token (edit-token zloc #(str s %)) + :multi-line (->> (fn [lines] + (if (empty? lines) + [s] + (update-in lines [0] #(str s %)))) + (edit-multi-line zloc )))) + +(defn suffix + [zloc s] + (case (base/tag zloc) + :token (edit-token zloc #(str % s)) + :multi-line (->> (fn [lines] + (if (empty? lines) + [s] + (concat (butlast lines) (str (last lines) s)))) + (edit-multi-line zloc)))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/findz.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/findz.cljs new file mode 100644 index 0000000..913865a --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/findz.cljs @@ -0,0 +1,147 @@ +(ns pez-rewrite-clj.zip.findz + (:refer-clojure :exclude [find]) + (:require [pez-rewrite-clj.zip.base :as base] + [pez-rewrite-clj.zip.move :as m] + [pez-rewrite-clj.node :as node] + [pez-rewrite-clj.zip.whitespace :as ws] + [clojure.zip :as z])) + +;; ## Helpers + +(defn- tag-predicate + [t & [additional]] + (if additional + (fn [node] + (and (= (base/tag node) t) + (additional node))) + #(= (base/tag %) t))) + + +(defn in-range? [{:keys [row col end-row end-col]} {r :row c :col}] + (and (>= r row) + (<= r end-row) + (if (= r row) (>= c col) true) + (if (= r end-row) (<= c end-col) true))) + + +;; ## Find Operations + +(defn find + "Find node satisfying the given predicate by repeatedly + applying the given movement function to the initial zipper + location." + ([zloc p?] + (find zloc m/right p?)) + ([zloc f p?] + (->> zloc + (iterate f) + (take-while identity) + (take-while (complement m/end?)) + (drop-while (complement p?)) + (first)))) + + + +(defn find-last-by-pos + "Find last node (if more than one node) that is in range of pos and + satisfying the given predicate depth first from initial zipper + location." + ([zloc pos] (find-last-by-pos zloc pos (constantly true))) + ([zloc pos p?] + (->> zloc + (iterate z/next) + (take-while identity) + (take-while (complement m/end?)) + (filter #(and (p? %) + (in-range? (-> % z/node meta) pos))) + last))) + + +(defn find-depth-first + "Find node satisfying the given predicate by traversing + the zipper in a depth-first way." + [zloc p?] + (find zloc m/next p?)) + + +(defn find-next + "Find node other than the current zipper location matching + the given predicate by applying the given movement function + to the initial zipper location." + ([zloc p?] + (find-next zloc m/right p?)) + ([zloc f p?] + (some-> zloc f (find f p?)))) + +(defn find-next-depth-first + "Find node other than the current zipper location matching + the given predicate by traversing the zipper in a + depth-first way." + [zloc p?] + (find-next zloc m/next p?)) + +(defn find-tag + "Find node with the given tag by repeatedly applying the given + movement function to the initial zipper location." + ([zloc t] + (find-tag zloc m/right t)) + ([zloc f t] + (find zloc f #(= (base/tag %) t)))) + +(defn find-next-tag + "Find node other than the current zipper location with the + given tag by repeatedly applying the given movement function to + the initial zipper location." + ([zloc t] + (find-next-tag zloc m/right t)) + ([zloc f t] + (->> (tag-predicate t) + (find-next zloc f)))) + + +(defn find-tag-by-pos + "Find node with the given tag and pos depth-first from initial zipper location." + ([zloc pos t] + (find-last-by-pos zloc pos #(= (base/tag %) t)))) + + + +(defn find-token + "Find token node matching the given predicate by applying the + given movement function to the initial zipper location, defaulting + to `right`." + ([zloc p?] + (find-token zloc m/right p?)) + ([zloc f p?] + (->> (tag-predicate :token p?) + (find zloc f)))) + +(defn find-next-token + "Find next token node matching the given predicate by applying the + given movement function to the initial zipper location, defaulting + to `right`." + ([zloc p?] + (find-next-token zloc m/right p?)) + ([zloc f p?] + (find-token (f zloc) f p?))) + +(defn find-value + "Find token node whose value matches the given one by applying the + given movement function to the initial zipper location, defaulting + to `right`." + ([zloc v] + (find-value zloc m/right v)) + ([zloc f v] + (let [p? (if (set? v) + (comp v base/sexpr) + #(= (base/sexpr %) v))] + (find-token zloc f p?)))) + +(defn find-next-value + "Find next token node whose value matches the given one by applying the + given movement function to the initial zipper location, defaulting + to `right`." + ([zloc v] + (find-next-value zloc m/right v)) + ([zloc f v] + (find-value (f zloc) f v))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/insert.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/insert.cljs new file mode 100644 index 0000000..cea1678 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/insert.cljs @@ -0,0 +1,55 @@ +(ns ^:no-doc pez-rewrite-clj.zip.insert + (:require [pez-rewrite-clj.zip.base :as base] + [pez-rewrite-clj.zip.whitespace :as ws] + [pez-rewrite-clj.node :as node] + [clojure.zip :as z])) + +(def ^:private space + (node/spaces 1)) + +(defn- insert + "Generic insertion helper. If the node reached by `move-fn` + is a whitespace, insert an additional space." + [move-fn insert-fn prefix zloc item] + (let [item-node (node/coerce item) + next-node (move-fn zloc)] + (->> (if (or (not next-node) (ws/whitespace? next-node)) + (concat [item-node] prefix) + (concat [space item-node] prefix)) + (reduce insert-fn zloc)))) + +(defn insert-right + "Insert item to the right of the current location. Will insert a space if necessary." + [zloc item] + (insert + z/right + z/insert-right + [space] + zloc item)) + +(defn insert-left + "Insert item to the right of the left location. Will insert a space if necessary." + [zloc item] + (insert + z/left + z/insert-left + [space] + zloc item)) + +(defn insert-child + "Insert item as first child of the current node. Will insert a space if necessary." + [zloc item] + (insert + z/down + z/insert-child + [] + zloc item)) + +(defn append-child + "Insert item as last child of the current node. Will insert a space if necessary." + [zloc item] + (insert + #(some-> % z/down z/rightmost) + z/append-child + [] + zloc item)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/move.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/move.cljs new file mode 100644 index 0000000..67d285e --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/move.cljs @@ -0,0 +1,73 @@ +(ns pez-rewrite-clj.zip.move + (:refer-clojure :exclude [next]) + (:require [pez-rewrite-clj.zip.whitespace :as ws] + [clojure.zip :as z])) + +(defn right + "Move right to next non-whitespace/non-comment location." + [zloc] + (some-> zloc z/right ws/skip-whitespace)) + +(defn left + "Move left to next non-whitespace/non-comment location." + [zloc] + (some-> zloc z/left ws/skip-whitespace-left)) + +(defn down + "Move down to next non-whitespace/non-comment location." + [zloc] + (some-> zloc z/down ws/skip-whitespace)) + +(defn up + "Move up to next non-whitespace/non-comment location." + [zloc] + (some-> zloc z/up ws/skip-whitespace-left)) + +(defn next + "Move to the next non-whitespace/non-comment location in a depth-first manner." + [zloc] + (when zloc + (or (some->> zloc + z/next + (ws/skip-whitespace z/next)) + (vary-meta zloc assoc ::end? true)))) + +(defn end? + "Check whether the given node is at the end of the depth-first traversal." + [zloc] + (or (not zloc) + (z/end? zloc) + (::end? (meta zloc)))) + +(defn rightmost? + "Check if the given location represents the leftmost non-whitespace/ + non-comment one." + [zloc] + (nil? (ws/skip-whitespace (z/right zloc)))) + +(defn leftmost? + "Check if the given location represents the leftmost non-whitespace/ + non-comment one." + [zloc] + (nil? (ws/skip-whitespace-left (z/left zloc)))) + +(defn prev + "Move to the next non-whitespace/non-comment location in a depth-first manner." + [zloc] + (some->> zloc + z/prev + (ws/skip-whitespace z/prev))) + +(defn leftmost + "Move to the leftmost non-whitespace/non-comment location." + [zloc] + (some-> zloc + z/leftmost + ws/skip-whitespace)) + +(defn rightmost + "Move to the rightmost non-whitespace/non-comment location." + [zloc] + (some-> zloc + z/rightmost + ws/skip-whitespace-left)) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/removez.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/removez.cljs new file mode 100644 index 0000000..6d82aa9 --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/removez.cljs @@ -0,0 +1,62 @@ +(ns pez-rewrite-clj.zip.removez + (:refer-clojure :exclude [remove]) + (:require [pez-rewrite-clj.zip.move :as m] + [pez-rewrite-clj.zip.utils :as u] + [pez-rewrite-clj.zip.whitespace :as ws] + [clojure.zip :as z])) + + +(defn- remove-trailing-space + "Remove all whitespace following a given node." + [zloc p?] + (u/remove-right-while zloc p?)) + +(defn- remove-preceding-space + "Remove all whitespace preceding a given node." + [zloc p?] + (u/remove-left-while zloc p?)) + +(defn remove + "Remove value at the given zipper location. Returns the first non-whitespace + node that would have preceded it in a depth-first walk. Will remove whitespace + appropriately. + + - `[1 2 3] => [1 3]` + - `[1 2] => [1]` + - `[1 2] => [2]` + - `[1] => []` + - `[ 1 ] => []` + - `[1 [2 3] 4] => [1 [2 3]]` + - `[1 [2 3] 4] => [[2 3] 4]` + + If a node is located rightmost, both preceding and trailing spaces are removed, + otherwise only trailing spaces are touched. This means that a following element + (no matter whether on the same line or not) will end up in the same position + (line/column) as the removed one, _unless_ a comment lies between the original + node and the neighbour." + [zloc] + {:pre [zloc] + :post [%]} + (->> (-> (if (or (m/rightmost? zloc) + (m/leftmost? zloc)) + (remove-preceding-space zloc ws/whitespace?) + zloc) + (remove-trailing-space ws/whitespace?) + z/remove) + (ws/skip-whitespace z/prev))) + +(defn remove-preserve-newline + "Same as remove but preserves newlines" + [zloc] + {:pre [zloc] + :post [%]} + (->> (-> (if (or (m/rightmost? zloc) + (m/leftmost? zloc)) + (remove-preceding-space zloc #(and (ws/whitespace? %) + (not (ws/linebreak? %)))) + zloc) + (remove-trailing-space #(and (ws/whitespace? %) + (not (ws/linebreak? %)))) + z/remove) + (ws/skip-whitespace z/prev))) + diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/seqz.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/seqz.cljs new file mode 100644 index 0000000..9dbdf7e --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/seqz.cljs @@ -0,0 +1,110 @@ +(ns pez-rewrite-clj.zip.seqz + (:refer-clojure :exclude [map get assoc seq? vector? list? map? set?]) + (:require [pez-rewrite-clj.zip.base :as base] + [pez-rewrite-clj.zip.editz :as e] + [pez-rewrite-clj.zip.findz :as f] + [pez-rewrite-clj.zip.insert :as i] + [pez-rewrite-clj.zip.move :as m] + [clojure.zip :as z])) + +;; ## Predicates + +(defn seq? + [zloc] + (contains? + #{:forms :list :vector :set :map} + (base/tag zloc))) + +(defn list? + [zloc] + (= (base/tag zloc) :list)) + +(defn vector? + [zloc] + (= (base/tag zloc) :vector)) + +(defn set? + [zloc] + (= (base/tag zloc) :set)) + +(defn map? + [zloc] + (= (base/tag zloc) :map)) + +;; ## Map Operations + +(defn- map-seq + [f zloc] + {:pre [(seq? zloc)]} + (if-let [n0 (m/down zloc)] + (some->> (f n0) + (iterate + (fn [loc] + (if-let [n (m/right loc)] + (f n)))) + (take-while identity) + (last) + (m/up)) + zloc)) + +(defn map-vals + "Apply function to all value nodes of the given map node." + [f zloc] + {:pre [(map? zloc)]} + (loop [loc (m/down zloc) + parent zloc] + (if-not (and loc (z/node loc)) + parent + (if-let [v0 (m/right loc)] + (if-let [v (f v0)] + (recur (m/right v) (m/up v)) + (recur (m/right v0) parent)) + parent)))) + +(defn map-keys + "Apply function to all key nodes of the given map node." + [f zloc] + {:pre [(map? zloc)]} + (loop [loc (m/down zloc) + parent zloc] + (if-not (and loc (z/node loc)) + parent + (if-let [v (f loc)] + (recur (m/right (m/right v)) (m/up v)) + (recur (m/right (m/right loc)) parent))))) + +(defn map + "Apply function to all value nodes in the given seq node. Iterates over + value nodes of maps but over each element of a seq." + [f zloc] + {:pre [(seq? zloc)]} + (if (map? zloc) + (map-vals f zloc) + (map-seq f zloc))) + +;; ## Get/Assoc + +(defn get + "If a map is given, get element with the given key; if a seq is given, get nth element." + [zloc k] + {:pre [(or (map? zloc) (and (seq? zloc) (integer? k)))]} + (if (map? zloc) + (some-> zloc m/down (f/find-value k) m/right) + (nth + (some->> (m/down zloc) + (iterate m/right) + (take-while identity)) + k))) + +(defn assoc + "Set map/seq element to the given value." + [zloc k v] + (if-let [vloc (get zloc k)] + (-> vloc (e/replace v) m/up) + (if (map? zloc) + (-> zloc + (i/append-child k) + (i/append-child v)) + (throw + (js/Error. + (str "index out of bounds: " k)))))) diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/utils.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/utils.cljs new file mode 100644 index 0000000..c2ea66e --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/utils.cljs @@ -0,0 +1,93 @@ +(ns ^:no-doc pez-rewrite-clj.zip.utils + (:require [clojure.zip :as z])) + +;; ## Remove + +(defn- update-in-path + [[node path :as loc] k f] + (let [v (get path k)] + (if (seq v) + (with-meta + [node (assoc path k (f v) :changed? true)] + (meta loc)) + loc))) + +(defn remove-right + "Remove right sibling of the current node (if there is one)." + [loc] + (update-in-path loc :r next)) + +(defn remove-left + "Remove left sibling of the current node (if there is one)." + [loc] + (update-in-path loc :l pop)) + + +(defn remove-while + [zloc p?] + "Remove nodes while predicate true. (depth first in reverse!) " + (loop [zloc zloc] + (let [ploc (z/prev zloc)] + (if-not (and ploc (p? ploc)) + zloc + (recur (z/remove zloc)))))) + +(defn remove-right-while + "Remove elements to the right of the current zipper location as long as + the given predicate matches." + [zloc p?] + (loop [zloc zloc] + (if-let [rloc (z/right zloc)] + (if (p? rloc) + (recur (remove-right zloc)) + zloc) + zloc))) + +(defn remove-left-while + "Remove elements to the left of the current zipper location as long as + the given predicate matches." + [zloc p?] + (loop [zloc zloc] + (if-let [lloc (z/left zloc)] + (if (p? lloc) + (recur (remove-left zloc)) + zloc) + zloc))) + +;; ## Remove and Move + +(defn remove-and-move-left + "Remove current node and move left. If current node is at the leftmost + location, returns `nil`." + [[_ {:keys [l] :as path} :as loc]] + (if (seq l) + (with-meta + [(peek l) (-> path + (update-in [:l] pop) + (assoc :changed? true))] + (meta loc)))) + +(defn remove-and-move-right + "Remove current node and move right. If current node is at the rightmost + location, returns `nil`." + [[_ {:keys [r] :as path} :as loc]] + (if (seq r) + (with-meta + [(first r) (-> path + (update-in [:r] next) + (assoc :changed? true))] + (meta loc)))) + + +(defn remove-and-move-up [loc] + (let [[node {l :l, ppath :ppath, pnodes :pnodes, rs :r, :as path}] loc] + (if (nil? path) + (throw (js/Error. "Remove at top")) + (if (pos? (count l)) + (z/up (with-meta [(peek l) + (assoc path :l (pop l) :changed? true)] + (meta loc))) + (with-meta [(z/make-node loc (peek pnodes) rs) + (and ppath (assoc ppath :changed? true))] + (meta loc)))))) + diff --git a/src/cljs-lib/src/pez_rewrite_clj/zip/whitespace.cljs b/src/cljs-lib/src/pez_rewrite_clj/zip/whitespace.cljs new file mode 100644 index 0000000..4a2707e --- /dev/null +++ b/src/cljs-lib/src/pez_rewrite_clj/zip/whitespace.cljs @@ -0,0 +1,76 @@ +(ns pez-rewrite-clj.zip.whitespace + (:require [pez-rewrite-clj.node :as node] + [clojure.zip :as z])) + +;; ## Predicates + +(defn whitespace? + [zloc] + (some-> zloc z/node node/whitespace?)) + +(defn linebreak? + [zloc] + (some-> zloc z/node node/linebreak?)) + +(defn comment? + [zloc] + (some-> zloc z/node node/comment?)) + +(defn whitespace-not-linebreak? + [zloc] + (and + (whitespace? zloc) + (not (linebreak? zloc)))) + +(defn whitespace-or-comment? + [zloc] + (some-> zloc z/node node/whitespace-or-comment?)) + + +;; ## Movement + +(defn skip + "Perform the given movement while the given predicate returns true." + [f p? zloc] + (->> (iterate f zloc) + (take-while identity) + (take-while (complement z/end?)) + (drop-while p?) + (first))) + +(defn skip-whitespace + "Perform the given movement (default: `z/right`) until a non-whitespace/ + non-comment node is encountered." + ([zloc] (skip-whitespace z/right zloc)) + ([f zloc] (skip f whitespace-or-comment? zloc))) + +(defn skip-whitespace-left + "Move left until a non-whitespace/non-comment node is encountered." + [zloc] + (skip-whitespace z/left zloc)) + +;; ## Insertion + +(defn prepend-space + "Prepend a whitespace node representing the given number of spaces (default: 1)." + ([zloc] (prepend-space zloc 1)) + ([zloc n] + (z/insert-left zloc (node/spaces n)))) + +(defn append-space + "Append a whitespace node representing the given number of spaces (default: 1)." + ([zloc] (append-space zloc 1)) + ([zloc n] + (z/insert-right zloc (node/spaces n)))) + +(defn prepend-newline + "Prepend a newlines node representing the given number of newlines (default: 1)." + ([zloc] (prepend-newline zloc 1)) + ([zloc n] + (z/insert-left zloc (node/newlines n)))) + +(defn append-newline + "Append a newline node representing the given number of newlines (default: 1)." + ([zloc] (append-newline zloc 1)) + ([zloc n] + (z/insert-right zloc (node/newlines n)))) diff --git a/src/cljs-lib/test/calva/fmt/editor_test.cljs b/src/cljs-lib/test/calva/fmt/editor_test.cljs new file mode 100644 index 0000000..21d094e --- /dev/null +++ b/src/cljs-lib/test/calva/fmt/editor_test.cljs @@ -0,0 +1,13 @@ +(ns calva.fmt.editor-test + (:require [cljs.test :include-macros true :refer [deftest is]] + [calva.fmt.editor :as sut])) + + +(deftest raplacement-edits-for-diffing-lines + (is (= [] + (sut/raplacement-edits-for-diffing-lines "foo\nfoo\nbar\nbar" + "foo\nfoo\nbar\nbar"))) + (is (= [{:edit "replace", :start {:line 1, :character 0}, :end {:line 1, :character 6}, :text "bar"} + {:edit "replace", :start {:line 2, :character 0}, :end {:line 2, :character 3}, :text "baz"}] + (sut/raplacement-edits-for-diffing-lines "foo\nfooooo\nbar\nbar" + "foo\nbar\nbaz\nbar")))) diff --git a/src/cljs-lib/test/calva/fmt/formatter_test.cljs b/src/cljs-lib/test/calva/fmt/formatter_test.cljs new file mode 100644 index 0000000..e409b20 --- /dev/null +++ b/src/cljs-lib/test/calva/fmt/formatter_test.cljs @@ -0,0 +1,344 @@ +(ns calva.fmt.formatter-test + (:require [cljs.test :include-macros true :refer [deftest is testing]] + [cljfmt.core :as cljfmt] + [calva.fmt.formatter :as sut])) + +(deftest format-text-at-range + (is (= "(foo)\n(defn bar\n [x]\n baz)" + (:range-text (sut/format-text-at-range {:eol "\n" :all-text " (foo)\n(defn bar\n[x]\nbaz)" :range [2 26]})))) + (is (not (contains? (sut/format-text-at-range {:eol "\n" :all-text " (foo)\n(defn bar\n[x]\nbaz)" :range [2 26]}) :new-index)))) + +(def all-text " (foo) + (defn bar + [x] + +baz)") + +(deftest format-text-at-idx + (is (= "(defn bar + [x] + + baz)" + (:range-text (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 11})))) + (is (= 1 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 11})))) + (is (= [10 38] + (:range (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 11})))) + (is (= [0 5] + (:range (sut/format-text-at-idx {:eol "\n" :all-text "(\n\n,)" :range [0 5] :idx 2})))) + (is (= "()" + (:range-text (sut/format-text-at-idx {:eol "\n" :all-text "(\n\n,)" :range [0 5] :idx 2}))))) + +(def misaligned-text "(def foo +(let[a b +aa bb +ccc {:a b :aa bb :ccc ccc}] +))") + +(deftest format-aligned-text-at-idx + (testing "Aligns associative structures when `:align-associative` is `true`" + (is (= "(def foo + (let [a b + aa bb + ccc {:a b + :aa bb + :ccc ccc}]))" + (:range-text (sut/format-text-at-idx {:eol "\n" + :all-text misaligned-text + :config {:align-associative? true} + :range [0 56] + :idx 0}))))) + + (testing "Does not align associative structures when `:align-associative` is not `true`" + (is (= "(def foo + (let [a b + aa bb + ccc {:a b :aa bb :ccc ccc}]))" + (:range-text (sut/format-text-at-idx {:eol "\n" + :all-text misaligned-text + :range [0 56] + :idx 1})))))) + +(deftest format-trim-text-at-idx + (testing "Trims space between forms when `:remove-multiple-non-indenting-spaces?` is `true`" + (is (= "(def foo + (let [a b + aa bb + ccc {:a b :aa bb :ccc ccc}]))" + (:range-text (sut/format-text-at-idx {:eol "\n" + :all-text misaligned-text + :config {:remove-multiple-non-indenting-spaces? true} + :range [0 56] + :idx 0}))))) + + (testing "Does not trim space between forms when `:remove-multiple-non-indenting-spaces?` is missing" + (is (= "(def foo + (let [a b + aa bb + ccc {:a b :aa bb :ccc ccc}]))" + (:range-text (sut/format-text-at-idx {:eol "\n" + :all-text misaligned-text + :range [0 56] + :idx 1})))))) + +(def a-comment + {:eol "\n" + :all-text " (foo) +(comment + (defn bar + [x] + +baz))" + :range [8 48] + :idx 47 + :config {:keep-comment-forms-trail-paren-on-own-line? true + :comment-form? true}}) + +(deftest format-text-w-comments-at-idx + (is (= {:new-index 38 + :range-text "(comment + (defn bar + [x] + + baz))"} + (select-keys (sut/format-text-at-idx + (assoc-in a-comment [:config :comment-form?] false)) + [:range-text :new-index]))) + + (is (= {:new-index 41 + :range-text "(comment + (defn bar + [x] + + baz) + )"} + (select-keys (sut/format-text-at-idx + (assoc a-comment :idx 47)) + [:range-text :new-index])))) + +(deftest new-index + (is (= 1 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 11})))) + (is (= 13 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 28})))) + (is (= 10 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 22})))) + (is (= 12 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 27})))) + (is (= 22 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text all-text :range [10 38] :idx 33})))) + (is (= 5 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text "(defn \n \nfoo)" :range [0 14] :idx 6})))) + (is (= 11 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text "(foo\n (bar)\n )" :range [0 14] :idx 11})))) + (is (= 1 + (:new-index (sut/format-text-at-idx {:eol "\n" :all-text "(\n\n,)" :range [0 14] :idx 2}))))) + + +(def head-and-tail-text "(def a 1) + + +(defn foo [x] (let [bar 1] + +bar))") + + +(deftest add-head-and-tail + (is (= {:head "" :tail head-and-tail-text + :all-text head-and-tail-text + :idx 0} + (sut/add-head-and-tail {:all-text head-and-tail-text :idx 0}))) + (is (= {:head head-and-tail-text :tail "" + :all-text head-and-tail-text + :idx (count head-and-tail-text)} + (sut/add-head-and-tail {:all-text head-and-tail-text :idx (count head-and-tail-text)}))) + (is (= {:head "(def a 1)\n\n\n(defn foo " + :tail "[x] (let [bar 1]\n\nbar))" + :all-text head-and-tail-text + :idx 22} + (sut/add-head-and-tail {:all-text head-and-tail-text :idx 22}))) + (is (= {:head head-and-tail-text :tail "" + :all-text head-and-tail-text + :idx (inc (count head-and-tail-text))} + (sut/add-head-and-tail {:all-text head-and-tail-text :idx (inc (count head-and-tail-text))})))) + + +(deftest normalize-indents + (is (= "(foo)\n (defn bar\n [x]\n baz)" + (:range-text (sut/normalize-indents {:eol "\n" + :all-text " (foo)\n(defn bar\n[x]\nbaz)" + :range [2 26] + :range-text "(foo)\n(defn bar\n [x]\n baz)"}))))) + + +(def first-top-level-text " +;; foo +(defn foo [x] + (* x x)) + ") + +(def mid-top-level-text ";; foo +(defn foo [x] + (* x x)) + +(bar)") + +(def last-top-level-text ";; foo +(defn foo [x] + (* x x)) + ") + + +(deftest format-text-at-idx-on-type + (is (= "(bar \n\n )" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(bar \n\n)" :range [0 8] :idx 7})))) + (is (= "(bar \n \n )" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(bar \n \n)" :range [0 9] :idx 8})))) + (is (= "(bar \n \n )" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(bar \n\n)" :range [0 8] :idx 6})))) + (is (= "\"bar \n \n \"" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text "\"bar \n \n \"" :range [0 10] :idx 7})))) + (is (= "\"bar \n \n \"" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text "\"bar \n \n \"" :range [0 10] :idx 7})))) + (is (= "'([]\n [])" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text " '([]\n[])" :range [2 10] :idx 7})))) + (is (= "[:foo\n \n (foo) (bar)]" + (:range-text (sut/format-text-at-idx-on-type {:eol "\n" :all-text "[:foo\n\n(foo)(bar)]" :range [0 18] :idx 6}))))) + + +(deftest new-index-on-type + (is (= 6 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn \n)" :range [0 8] :idx 6})))) + (is (= 8 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn\n\n)" :range [0 8] :idx 6})))) + #_(is (= 8 ;; Fails due to a bug in rewrite-cljs + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn\n\n#_)" :range [0 10] :idx 6})))) + (is (= 9 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn \n)" :range [0 8] :idx 7})))) + (is (= 7 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn \n )" :range [0 10] :idx 7})))) + (is (= 9 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn \n \n )" :range [0 13] :idx 9})))) + (is (= 9 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn \n\n)" :range [0 9] :idx 7})))) + (is (= 10 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(defn \n\n)" :range [0 9] :idx 8})))) + (is (= 13 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "(foo\n (bar)\n)" :range [0 13] :idx 12})))) + (is (= 7 + (:new-index (sut/format-text-at-idx-on-type {:eol "\n" :all-text "[:foo\n\n(foo)(bar)]" :range [0 18] :idx 6}))))) + + +(deftest new-index-on-type-crlf + (is (= 6 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(defn \r\n)" :range [0 9] :idx 6})))) + (is (= 10 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(defn \r\n)" :range [0 9] :idx 8})))) + (is (= 8 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(defn \r\n )" :range [0 11] :idx 8})))) + (is (= 10 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(defn \r\n \r\n )" :range [0 15] :idx 10})))) + (is (= 10 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(defn \r\n\r\n)" :range [0 11] :idx 8})))) + (is (= 12 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(defn \r\n\r\n)" :range [0 11] :idx 10})))) + (is (= 15 + (:new-index (sut/format-text-at-idx-on-type {:eol "\r\n" :all-text "(foo\r\n (bar)\r\n)" :range [0 15] :idx 14}))))) + + +(deftest index-for-tail-in-range + (is (= 7 + (:new-index (sut/index-for-tail-in-range + {:range-text "foo te x t" + :range-tail " x t"})))) + (is (= 169 + (:new-index (sut/index-for-tail-in-range + {:range-text "(create-state \"\" + \"### \" + \" ###\" + \" ### \" + \" # \")" + :range-tail "\" # \")"}))))) + + +(deftest remove-indent-token-if-empty-current-line + (is (= {:range-text "foo\n\nbar" + :range [4 4] + :current-line "" + :new-index 4} + (sut/remove-indent-token-if-empty-current-line {:range-text "foo\n0\nbar" + :range [4 5] + :new-index 4 + :current-line ""}))) + (is (= {:range-text "foo\n0\nbar" + :range [4 5] + :current-line "0" + :new-index 4} + (sut/remove-indent-token-if-empty-current-line {:range-text "foo\n0\nbar" + :range [4 5] + :new-index 4 + :current-line "0"})))) + + +(deftest current-line-empty? + (is (= true (sut/current-line-empty? {:current-line " "}))) + (is (= false (sut/current-line-empty? {:current-line " foo "})))) + + +(deftest indent-before-range + (is (= 10 + (sut/indent-before-range {:all-text "(def a 1) + + +(defn foo [x] (let [bar 1] + +bar))" :range [22 25]}))) + (is (= 4 + (sut/indent-before-range {:all-text " '([] +[])" :range [4 9]})))) + + +(deftest read-cljfmt + (is (= (count cljfmt/default-indents) + (count (:indents (sut/read-cljfmt "{}")))) + "by default uses cljfmt indent rules") + (is (= (+ 2 (count cljfmt/default-indents)) + (count (:indents (sut/read-cljfmt "{:indents {foo [[:inner 0]] bar [[:block 1]]}}")))) + "merges indents on top of cljfmt indent rules") + (is (= {'a [[:inner 0]]} + (:indents (sut/read-cljfmt "{:indents ^:replace {a [[:inner 0]]}}"))) + "with :replace metadata hint overrides default indents") + (is (= false + (:align-associative? (sut/read-cljfmt "{}"))) + ":align-associative? is false by default.") + (is (= true + (:align-associative? (sut/read-cljfmt "{:align-associative? true}"))) + "including keys in cljfmt such as :align-associative? will override defaults.") + (is (= true + (:remove-surrounding-whitespace? (sut/read-cljfmt "{}"))) + ":remove-surrounding-whitespace? is true by default.") + (is (= false + (:remove-surrounding-whitespace? (sut/read-cljfmt "{:remove-surrounding-whitespace? false}"))) + "including keys in cljfmt such as :remove-surrounding-whitespace? will override defaults.") + (is (nil? (:foo (sut/read-cljfmt "{:bar false}"))) + "most keys don't have any defaults.")) + +(deftest cljfmt-options + (is (= (count cljfmt/default-indents) + (count (:indents (sut/merge-cljfmt {})))) + "by default uses cljfmt indent rules") + (is (= (+ 2 (count cljfmt/default-indents)) + (count (:indents (sut/merge-cljfmt '{:indents {foo [[:inner 0]] bar [[:block 1]]}})))) + "merges indents on top of cljfmt indent rules") + (is (= {'a [[:inner 0]]} + (:indents (sut/merge-cljfmt '{:indents ^:replace {a [[:inner 0]]}}))) + "with :replace metadata hint overrides default indents") + (is (= true + (:align-associative? (sut/merge-cljfmt {:align-associative? true + :cljfmt-string "{:align-associative? false}"}))) + "cljfmt :align-associative? has lower priority than config's option") + (is (= false + (:align-associative? (sut/merge-cljfmt {:cljfmt-string "{}"}))) + ":align-associative? is false by default") + (is (nil? (:foo (sut/merge-cljfmt {:cljfmt-string "{:bar false}"}))) + "most keys don't have any defaults.")) diff --git a/src/cljs-lib/test/calva/fmt/util_test.cljs b/src/cljs-lib/test/calva/fmt/util_test.cljs new file mode 100644 index 0000000..7a822f5 --- /dev/null +++ b/src/cljs-lib/test/calva/fmt/util_test.cljs @@ -0,0 +1,49 @@ +(ns calva.fmt.util-test + (:require [cljs.test :include-macros true :refer [deftest is]] + [calva.fmt.util :as sut])) + + +#_(deftest log + (is (= (with-out-str (sut/log {:range-text ""} :range-text)) + {:range-text ""}))) + + +(def all-text "(def a 1) + + +(defn foo [x] (let [bar 1] + +bar))") + + +(deftest current-line + (is (= "(def a 1)" (sut/current-line all-text 0))) + (is (= "(def a 1)" (sut/current-line all-text 4))) + (is (= "(def a 1)" (sut/current-line all-text 9))) + (is (= "" (sut/current-line all-text 10))) + (is (= "" (sut/current-line all-text 11))) + (is (= "(defn foo [x] (let [bar 1]" (sut/current-line all-text 12))) + (is (= "(defn foo [x] (let [bar 1]" (sut/current-line all-text 27))) + (is (= "(defn foo [x] (let [bar 1]" (sut/current-line all-text 38))) + (is (= "" (sut/current-line all-text 39))) + (is (= "bar))" (sut/current-line all-text (count all-text))))) + + +(deftest re-pos-one + (is (= 6 + (sut/re-pos-first "\\s*x\\s*t$" "foo te x t"))) + (is (= 6 + (sut/re-pos-first "\\s*x\\s*t$" "foo te x t"))) + (is (= 5 + (sut/re-pos-first "\\s*e\\s*xt\\s*$" "foo te xt"))) + (is (= 173 + (sut/re-pos-first "\"\\s*#\\s*\"\\)$" "(create-state \"\" + \"### \" + \" ###\" + \" ### \" + \" # \")")))) + + +(deftest escape-regexp + (is (= "\\.\\*" + (sut/escape-regexp ".*")))) diff --git a/src/cljs-lib/test/calva/js2cljs/converter_test.cljs b/src/cljs-lib/test/calva/js2cljs/converter_test.cljs new file mode 100644 index 0000000..7dcafe5 --- /dev/null +++ b/src/cljs-lib/test/calva/js2cljs/converter_test.cljs @@ -0,0 +1,23 @@ +(ns calva.js2cljs.converter-test + (:require [calva.js2cljs.converter :as sut] + [clojure.spec.alpha :as s] + [cljs.test :refer [testing deftest is]])) + +;; valid result +(s/def ::result string?) + +;; invalid result +(s/def ::message string?) +(s/def ::number-of-parsed-lines pos-int?) +(s/def ::name string?) +(s/def ::exception (s/keys :req-un [::name ::message])) +(s/def ::error (s/keys :req-un [::message ::number-of-parsed-lines ::exception])) +(s/def ::invalid-result (s/keys :req-un [::error])) + +(deftest valid-results-test + (testing "Returns a map with a `:result` string entry when conversion succeeds" + (is (s/valid? ::result (:result (sut/convert "foo")))))) + +(deftest invalid-results-test + (testing "Returns a map with an `:error` entry when conversion fails" + (is (s/valid? ::invalid-result (sut/convert "import * as foo from 'foo';"))))) \ No newline at end of file diff --git a/src/cljs-lib/test/calva/js_utils_test.cljs b/src/cljs-lib/test/calva/js_utils_test.cljs new file mode 100644 index 0000000..aba400f --- /dev/null +++ b/src/cljs-lib/test/calva/js_utils_test.cljs @@ -0,0 +1,11 @@ +(ns calva.js-utils-test + (:require [cljs.test :refer [deftest is testing]] + [calva.js-utils :refer [jsify]])) + +(deftest jsify-test + (testing "Converts map with vector containing map" + (is (= (pr-str (jsify {:foo [1 {:bar :baz}]})) + (pr-str #js {:foo #js [1 #js {:bar "baz"}]})))) + (testing "Converts map with namespaced keywords" + (is (= (pr-str (jsify {:foo/bar :foo/bar})) + (pr-str #js {"foo/bar" "foo/bar"}))))) \ No newline at end of file diff --git a/src/cljs-lib/test/calva/parse_test.cljs b/src/cljs-lib/test/calva/parse_test.cljs new file mode 100644 index 0000000..81fbc22 --- /dev/null +++ b/src/cljs-lib/test/calva/parse_test.cljs @@ -0,0 +1,38 @@ +(ns calva.parse-test + (:require [cljs.test :refer [testing is deftest]] + [calva.parse :refer [parse-edn parse-forms parse-clj-edn]])) + +(deftest parse-edn-test + (testing "Should parse form preceded by #= as is" + (is (= (parse-edn "#=(+ 1 2)") "#=(+ 1 2)"))) + (testing "Should parse map with keyword key and vector value" + (is (= (parse-edn "{:foo [1 2]}") {:foo [1 2]}))) + (testing "Should parse map with namespaced keyword key and vector value" + (is (= (parse-edn "{:foo/bar [1 2]}") {:foo/bar [1 2]}))) + (testing "Should return first form if input is multiple top level forms" + (is (= (parse-edn ":a {:foo ['bar] :bar 'foo}") :a)))) + +(deftest parse-forms-test + (testing "Should parse keyword, map, vector, symbol, and should add ' before symbols" + (is (= (parse-forms ":a {:foo [bar] :bar foo}") + [:a {:foo ['bar] :bar 'foo}]))) + (testing "Should parse quote symbol into `quote` and should parse forms preceded by #= as nil" + (is (= (parse-forms ":a {:foo ['bar] :bar 'foo} #=(+ 1 2)") + [:a {:foo ['(quote bar)] :bar '(quote foo)} nil]))) + (testing "Should parse forms preceded by #= as nil (and not evaluate them)" + (is (= (parse-forms "{:a #=(1 + 2)}") + [{:a nil}])))) + +(deftest parse-clj-edn-test + (testing "Should parse nil as nil" + (is (= (parse-clj-edn nil) nil))) + (testing "Should parse map with keyword key and vector" + (is (= (parse-clj-edn "{:foo [1 2]}") {:foo [1 2]}))) + (testing "Should parse map with namespaced keyword key and vector" + (is (= (parse-clj-edn "{:foo/bar [1 2]}") {:foo/bar [1 2]}))) + (testing "Should return first form if input is multiple top level forms" + (is (= :a (parse-clj-edn ":a {:foo ['bar] :bar 'foo}")))) + (testing "Should parse edn regexp as js regexp" + (is (= js/RegExp (type (parse-clj-edn "#\"^foo.*bar$\""))))) + (testing "Should return string representation of regexp as string" + (is (= "/^foo.*bar$/" (str (parse-clj-edn "#\"^foo.*bar$\"")))))) \ No newline at end of file diff --git a/src/cljs-lib/test/calva/pprint/printer_test.cljs b/src/cljs-lib/test/calva/pprint/printer_test.cljs new file mode 100644 index 0000000..c261991 --- /dev/null +++ b/src/cljs-lib/test/calva/pprint/printer_test.cljs @@ -0,0 +1,35 @@ +(ns calva.pprint.printer-test + (:require [cljs.test :refer [testing deftest is]] + [calva.pprint.printer :refer [pretty-print]] + [clojure.string :as str])) + +(deftest pretty-print-test + (letfn [(pretty-line-of [n s opts] + (as-> `[~@(repeat n s)] x + (pretty-print x opts) + (:value x) + (str/split x #"\n") + (take 1 x)))] + (let [deep [[[[[[[[[[[[[[[[[{:foo [:bar]}]]]]]]]]]]]]]]]]] + shallow [[:foo]]] + (testing "String input" + (is (= "[[[:foo]]]" + (:value (pretty-print "[ [ [:foo + ]] ]" nil))))) + (testing "Valid and invalid EDN" + (is (= "[1]" + (:value (pretty-print "[ 1]" nil)))) + (is (= "[ 1" + (:value (pretty-print "[ 1" nil))))) + (testing "Default printing options" ; zprint default width 80 + (let [width (apply count (pretty-line-of 25 "foo" nil))] + (is (> width 70)) + (is (<= width 80))) + (is (not (re-find #"#" (:value (pretty-print deep nil)))))) + (testing "Settings" + (let [width (apply count (pretty-line-of 25 "foo" {:width 40}))] + (is (> width 30)) + (is (<= width 40))) + (let [width (apply count (pretty-line-of 25 "foo" {:max-length 2}))] + (is (< width 20))) + (is (re-find #"#" (:value (pretty-print shallow {:max-depth 1})))))))) \ No newline at end of file diff --git a/src/cljs-lib/test/calva/state_test.cljs b/src/cljs-lib/test/calva/state_test.cljs new file mode 100644 index 0000000..db14acd --- /dev/null +++ b/src/cljs-lib/test/calva/state_test.cljs @@ -0,0 +1,31 @@ +(ns calva.state-test + (:require [cljs.test :refer [testing deftest is use-fixtures]] + [calva.state :as state])) + +(use-fixtures :each + {:before (fn [] (reset! state/state {}))}) + +(deftest set-state-value!-test + (testing "Should write value to state, given key" + (state/set-state-value! "hello" "world") + (is (= {"hello" "world"} @state/state)))) + +(deftest remove-state-value!-test + (testing "Should remove value from state, given key" + (reset! state/state {"hello" "world"}) + (state/remove-state-value! "hello") + (is (= {} @state/state)))) + +(deftest get-state-value-test + (testing "Should get value from state, given key" + (reset! state/state {"hello" "world"}) + (is (= "world" (state/get-state-value "hello"))))) + +(deftest get-state-test + (testing "Should return all state" + (let [all-state {"hello" "world" "fizz" "buzz"}] + (reset! state/state all-state) + (is (= all-state (state/get-state)))))) + +(comment + (state/get-state)) \ No newline at end of file diff --git a/src/cljs-lib/test/pez_rewrite_clj/node_test.cljs b/src/cljs-lib/test/pez_rewrite_clj/node_test.cljs new file mode 100644 index 0000000..d131c7c --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/node_test.cljs @@ -0,0 +1,55 @@ +(ns pez-rewrite-clj.node-test + (:require [cljs.test :refer-macros [deftest is testing run-tests]] + [pez-rewrite-clj.node :as n] + [pez-rewrite-clj.parser :as p])) + + +(deftest namespaced-keyword + (is (= ":dill/dall" + (n/string (n/keyword-node :dill/dall))))) + +(deftest funky-keywords + (is (= ":%dummy.*" + (n/string (n/keyword-node :%dummy.*))))) + +(deftest regex-node + (let [sample "(re-find #\"(?i)RUN\" s)" + sample2 "(re-find #\"(?m)^rss\\s+(\\d+)$\")" + sample3 "(->> (str/split container-name #\"/\"))"] + (is (= sample (-> sample p/parse-string n/string))) + (is (= sample2 (-> sample2 p/parse-string n/string))) + (is (= sample3 (-> sample3 p/parse-string n/string))))) + + +(deftest regex-with-newlines + (let [sample "(re-find #\"Hello + \\nJalla\")"] + (is (= sample (-> sample p/parse-string n/string))))) + + + +(deftest reader-conditionals + (testing "Simple reader conditional" + (let [sample "#?(:clj bar)" + res (p/parse-string sample)] + (is (= sample (n/string res))) + (is (= :reader-macro (n/tag res))) + (is (= [:token :list] (map n/tag (n/children res)))))) + + (testing "Reader conditional with space before list" + (let [sample "#? (:clj bar)" + sample2 "#?@ (:clj bar)"] + (is (= sample (-> sample p/parse-string n/string))) + (is (= sample2 (-> sample2 p/parse-string n/string))))) + + + (testing "Reader conditional with splice" + (let [sample +"(:require [clojure.string :as s] + #?@(:clj [[clj-time.format :as tf] + [clj-time.coerce :as tc]] + :cljs [[cljs-time.coerce :as tc] + [cljs-time.format :as tf]]))" + res (p/parse-string sample)] + (is (= sample (n/string res)))))) + diff --git a/src/cljs-lib/test/pez_rewrite_clj/paredit_test.cljs b/src/cljs-lib/test/pez_rewrite_clj/paredit_test.cljs new file mode 100644 index 0000000..0ea22f7 --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/paredit_test.cljs @@ -0,0 +1,560 @@ +(ns pez-rewrite-clj.paredit-test + (:require [cljs.test :refer-macros [deftest is testing run-tests]] + [pez-rewrite-clj.zip :as z] + [clojure.zip :as zz] + [pez-rewrite-clj.paredit :as pe])) + + + +;; helper +(defn move-n [loc f n] + (->> loc (iterate f) (take n) last)) + + +(deftest kill-to-end-of-sexpr + (let [res (-> "[1 2 3 4]" + z/of-string + z/down zz/right + pe/kill)] + (is (= "[1]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + +(deftest kill-to-end-of-line + (let [res (-> "[1 2] ; useless comment" + z/of-string + zz/right + pe/kill)] + (is (= "[1 2]" (-> res z/root-string))) + (is (= "[1 2]" (-> res z/string))))) + +(deftest kill-to-wipe-all-sexpr-contents + (let [res (-> "[1 2 3 4]" + z/of-string + z/down + pe/kill)] + (is (= "[]" (-> res z/root-string))) + (is (= "[]" (-> res z/string))))) + +(deftest kill-to-wipe-all-sexpr-contents-in-nested-seq + (let [res (-> "[[1 2 3 4]]" + z/of-string + z/down + pe/kill)] + (is (= "[]" (-> res z/root-string))) + (is (= "[]" (-> res z/string))))) + +(deftest kill-when-left-is-sexpr + (let [res (-> "[1 2 3 4] 2" + z/of-string + zz/right + pe/kill)] + (is (= "[1 2 3 4]" (-> res z/root-string))) + (is (= "[1 2 3 4]" (-> res z/string))))) + +(deftest kill-it-all + (let [res (-> "[1 2 3 4] 5" + z/of-string + pe/kill)] + (is (= "" (-> res z/root-string))) + (is (= "" (-> res z/string))))) + + + +(deftest kill-at-pos-when-in-empty-seq + (let [res (-> "[] 5" + z/of-string + (pe/kill-at-pos {:row 1 :col 2}))] + (is (= "5" (-> res z/root-string))) + (is (= "5" (-> res z/string))))) + + +(deftest kill-inside-comment + (is (= "; dill" (-> "; dilldall" + z/of-string + (pe/kill-at-pos {:row 1 :col 7}) + z/root-string)))) + +(deftest kill-at-pos-when-string + (let [res (-> "(str \"Hello \" \"World!\")" + z/of-string + z/down + (pe/kill-at-pos {:row 1 :col 9}))] + (is (= "(str \"He\" \"World!\")" (-> res z/root-string))))) + + +(deftest kill-at-pos-when-string-multiline + (let [sample "(str \" +First line + Second Line + Third Line + \")" + expected "(str \" +First line + Second\")" + + res (-> sample + z/of-string + z/down + (pe/kill-at-pos {:row 3 :col 9}))] + (is (= expected (-> res z/root-string))))) + + + + +(deftest kill-at-pos-multiline-aligned + (let [sample " +(println \"Hello + There + World\")"] + (is (= "\n(println \"Hello\")" (-> sample + z/of-string + (pe/kill-at-pos {:row 2 :col 16}) + (z/root-string)))))) + + + +(deftest kill-at-pos-when-empty-string + (is (= "" (-> (z/of-string "\"\"") (pe/kill-at-pos {:row 1 :col 1}) z/root-string)))) + + + +(deftest kill-one-at-pos + (let [sample "[10 20 30]"] + (is (= "[10 30]" + (-> (z/of-string sample) + (pe/kill-one-at-pos {:row 1 :col 4}) ; at whitespace + z/root-string))) + (is (= "[10 30]" + (-> (z/of-string sample) + (pe/kill-one-at-pos {:row 1 :col 5}) + z/root-string))))) + +(deftest kill-one-at-pos-new-zloc-is-left-node + (let [sample "[[10] 20 30]"] + (is (= "[10]" + (-> (z/of-string sample) + (pe/kill-one-at-pos {:row 1 :col 6}) + z/string))) + (is (= "[10]" + (-> (z/of-string sample) + (pe/kill-one-at-pos {:row 1 :col 7}) + z/string))))) + +(deftest kill-one-at-pos-keep-linebreaks + (let [sample (z/of-string "[10\n 20\n 30]")] + (is (= "[20\n 30]" + (-> sample (pe/kill-one-at-pos {:row 1 :col 2}) z/root-string))) + (is (= "[10\n 30]" + (-> sample (pe/kill-one-at-pos {:row 2 :col 1}) z/root-string))) + (is (= "[10\n 20]" + (-> sample (pe/kill-one-at-pos {:row 3 :col 1}) z/root-string))))) + +(deftest kill-one-at-pos-in-comment + (let [sample (z/of-string "; hello world")] + (is (= "; hello " + (-> (pe/kill-one-at-pos sample {:row 1 :col 8}) z/root-string))) + (is (= "; hello " + (-> (pe/kill-one-at-pos sample {:row 1 :col 9}) z/root-string))) + (is (= "; hello " + (-> (pe/kill-one-at-pos sample {:row 1 :col 13}) z/root-string))) + (is (= "; world" + (-> (pe/kill-one-at-pos sample {:row 1 :col 2}) z/root-string))))) + +(deftest kill-one-at-pos-in-string + (let [sample (z/of-string "\"hello world\"")] + (is (= "\"hello \"" + (-> (pe/kill-one-at-pos sample {:row 1 :col 7}) z/root-string))) + (is (= "\"hello \"" + (-> (pe/kill-one-at-pos sample {:row 1 :col 8}) z/root-string))) + (is (= "\"hello \"" + (-> (pe/kill-one-at-pos sample {:row 1 :col 12}) z/root-string))) + (is (= "\" world\"" + (-> (pe/kill-one-at-pos sample {:row 1 :col 2}) z/root-string))))) + + +(deftest kill-one-at-pos-in-multiline-string + (let [sample (z/of-string "\"foo bar do\n lorem\"")] + (is (= "\" bar do\n lorem\"" + (-> (pe/kill-one-at-pos sample {:row 1 :col 2}) z/root-string))) + (is (= "\"foo bar do\n \"" + (-> (pe/kill-one-at-pos sample {:row 2 :col 1}) z/root-string))) + (is (= "\"foo bar \n lorem\"" + (-> (pe/kill-one-at-pos sample {:row 1 :col 10}) z/root-string))))) + + + +(deftest slurp-forward-and-keep-loc-rightmost + (let [res (-> "[[1 2] 3 4]" + z/of-string + z/down z/down z/right + pe/slurp-forward)] + (is (= "[[1 2 3] 4]" (-> res z/root-string))) + (is (= "2" (-> res z/string))))) + +(deftest slurp-forward-and-keep-loc-leftmost + (let [res (-> "[[1 2] 3 4]" + z/of-string + z/down z/down + pe/slurp-forward)] + (is (= "[[1 2 3] 4]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + +(deftest slurp-forward-from-empty-sexpr + (let [res (-> "[[] 1 2 3]" + z/of-string + z/down + pe/slurp-forward)] + (is (= "[[1] 2 3]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + +(deftest slurp-forward-from-whitespace-node + (let [res (-> "[[1 2] 3 4]" + z/of-string + z/down z/down zz/right + pe/slurp-forward)] + (is (= "[[1 2 3] 4]" (-> res z/root-string))) + (is (= " " (-> res z/string))))) + +(deftest slurp-forward-nested + (let [res (-> "[[[1 2]] 3 4]" + z/of-string + z/down z/down z/down + pe/slurp-forward)] + (is (= "[[[1 2] 3] 4]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + +(deftest slurp-forward-nested-silly + (let [res (-> "[[[[[1 2]]]] 3 4]" + z/of-string + z/down z/down z/down z/down z/down + pe/slurp-forward)] + (is (= "[[[[[1 2]]] 3] 4]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + +(deftest slurp-forward-when-last-is-sexpr + (let [res (-> "[1 [2 [3 4]] 5]" + z/of-string + z/down z/right z/down ;at 2 + pe/slurp-forward)] + (is (= "[1 [2 [3 4] 5]]" (-> res z/root-string)) + (= "2" (-> res z/string))))) + +(deftest slurp-forward-keep-linebreak + (let [sample " +(let [dill] + {:a 1} + {:b 2})" + expected "\n(let [dill \n{:a 1}]\n {:b 2})"] + (is (= expected (-> sample + z/of-string + z/down z/right z/down + pe/slurp-forward + z/root-string))))) + +(deftest slurp-forward-fully + (is (= "[1 [2 3 4]]" (-> (z/of-string "[1 [2] 3 4]") + z/down z/right z/down + pe/slurp-forward-fully + z/root-string)))) + + + +(deftest slurp-backward-and-keep-loc-leftmost + (let [res (-> "[1 2 [3 4]]" + z/of-string + z/down z/rightmost z/down + pe/slurp-backward)] + (is (= "[1 [2 3 4]]" (-> res z/root-string))) + (is (= "3" (-> res z/string))))) + +(deftest slurp-backward-and-keep-loc-rightmost + (let [res (-> "[1 2 [3 4]]" + z/of-string + z/down z/rightmost z/down z/rightmost + pe/slurp-backward)] + (is (= "[1 [2 3 4]]" (-> res z/root-string))) + (is (= "4" (-> res z/string))))) + +(deftest slurp-backward-from-empty-sexpr + (let [res (-> "[1 2 3 4 []]" + z/of-string + z/down z/rightmost + pe/slurp-backward)] + (is (= "[1 2 3 [4]]" (-> res z/root-string))) + (is (= "4" (-> res z/string))))) + +(deftest slurp-backward-nested + (let [res (-> "[1 2 [[3 4]]]" + z/of-string + z/down z/rightmost z/down z/down z/rightmost + pe/slurp-backward)] + (is (= "[1 [2 [3 4]]]" (-> res z/root-string))) + (is (= "4" (-> res z/string))))) + +(deftest slurp-backward-nested-silly + (let [res (-> "[1 2 [[[3 4]]]]" + z/of-string + z/down z/rightmost z/down z/down z/down z/rightmost + pe/slurp-backward)] + (is (= "[1 [2 [[3 4]]]]" (-> res z/root-string))) + (is (= "4" (-> res z/string))))) + +(deftest slurp-backward-keep-linebreaks-and-comments + (let [res (-> "[1 2 ;dill\n [3 4]]" + z/of-string + z/down z/rightmost z/down + pe/slurp-backward)] + (is (= "[1 [2 ;dill\n 3 4]]" (-> res z/root-string))))) + + +(deftest slurp-backward-fully + (is (= "[[1 2 3 4] 5]" (-> (z/of-string "[1 2 3 [4] 5]") + z/down z/rightmost z/left z/down + pe/slurp-backward-fully + z/root-string)))) + + +(deftest barf-forward-and-keep-loc + (let [res (-> "[[1 2 3] 4]" + z/of-string + z/down z/down z/right; position at 2 + pe/barf-forward)] + (is (= "[[1 2] 3 4]" (-> res z/root-string))) + (is (= "2" (-> res z/string))))) + +(deftest barf-forward-at-leftmost + (let [res (-> "[[1 2 3] 4]" + z/of-string + z/down z/down + pe/barf-forward)] + (is (= "[[1 2] 3 4]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + + +(deftest barf-forward-at-rightmost-moves-out-of-sexrp + (let [res (-> "[[1 2 3] 4]" + z/of-string + z/down z/down z/rightmost; position at 3 + pe/barf-forward)] + + (is (= "[[1 2] 3 4]" (-> res z/root-string))) + (is (= "3" (-> res z/string))))) + +(deftest barf-forward-at-rightmost-which-is-a-whitespace-haha + (let [res (-> "[[1 2 3 ] 4]" + z/of-string + z/down z/down zz/rightmost; position at space at the end + pe/barf-forward)] + + (is (= "[[1 2] 3 4]" (-> res z/root-string))) + (is (= "3" (-> res z/string))))) + + +(deftest barf-forward-at-when-only-one + (let [res (-> "[[1] 2]" + z/of-string + z/down z/down + pe/barf-forward)] + + (is (= "[[] 1 2]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + + + + +(deftest barf-backward-and-keep-current-loc + (let [res (-> "[1 [2 3 4]]" + z/of-string + z/down z/rightmost z/down z/rightmost ; position at 4 + pe/barf-backward)] + (is (= "[1 2 [3 4]]" (-> res z/root-string))) + (is (= "4" (-> res z/string))))) + +(deftest barf-backward-at-leftmost-moves-out-of-sexpr + (let [res (-> "[1 [2 3 4]]" + z/of-string + z/down z/rightmost z/down ; position at 2 + pe/barf-backward)] + (is (= "[1 2 [3 4]]" (-> res z/root-string))) + (is (= "2" (-> res z/string))))) + + +(deftest wrap-around + (is (= "(1)" (-> (z/of-string "1") (pe/wrap-around :list) z/root-string))) + (is (= "[1]" (-> (z/of-string "1") (pe/wrap-around :vector) z/root-string))) + (is (= "{1}" (-> (z/of-string "1") (pe/wrap-around :map) z/root-string))) + (is (= "#{1}" (-> (z/of-string "1") (pe/wrap-around :set) z/root-string)))) + +(deftest wrap-around-keeps-loc + (let [res (-> "1" + z/of-string + (pe/wrap-around :list))] + (is (= "1" (-> res z/string))))) + +(deftest wrap-around-keeps-newlines + (is (= "[[1]\n 2]" (-> (z/of-string "[1\n 2]") z/down (pe/wrap-around :vector) z/root-string)))) + + + +(deftest wrap-around-fn + (is (= "(-> (#(+ 1 1)))" (-> (z/of-string "(-> #(+ 1 1))") + z/down z/right + (pe/wrap-around :list) + z/root-string)))) + + +(deftest wrap-fully-forward-slurp + (is (= "[1 [2 3 4]]" + (-> (z/of-string "[1 2 3 4]") + z/down z/right + (pe/wrap-fully-forward-slurp :vector) + z/root-string)))) + +(deftest splice-killing-backward [] + (let [res (-> (z/of-string "(foo (let ((x 5)) (sqrt n)) bar)") + z/down z/right z/down z/right z/right + pe/splice-killing-backward)] + (is (= "(foo (sqrt n) bar)" (z/root-string res))) + (is (= "(sqrt n)" (z/string res))))) + + +(deftest splice-killing-forward [] + (let [res (-> (z/of-string "(a (b c d e) f)") + z/down z/right z/down z/right z/right + pe/splice-killing-forward)] + (is (= "(a b c f)" (z/root-string res))) + (is (= "c" (z/string res))))) + +(deftest splice-killing-forward-at-leftmost [] + (let [res (-> (z/of-string "(a (b c d e) f)") + z/down z/right z/down + pe/splice-killing-forward)] + (is (= "(a f)" (z/root-string res))) + (is (= "a" (z/string res))))) + + +(deftest split + (let [res (-> "[1 2]" + z/of-string + z/down + pe/split)] + (is (= "[1] [2]" (-> res z/root-string))) + (is (= "1" (-> res z/string))))) + +(deftest split-includes-node-at-loc-as-left + (let [res (-> "[1 2 3 4]" + z/of-string + z/down z/right + pe/split)] + (is (= "[1 2] [3 4]" (-> res z/root-string))) + (is (= "2" (-> res z/string))))) + + +(deftest split-at-whitespace + (let [res (-> "[1 2 3 4]" + z/of-string + z/down z/right zz/right + pe/split)] + (is (= "[1 2] [3 4]" (-> res z/root-string))) + (is (= "2" (-> res z/string))))) + + + + +(deftest split-includes-comments-and-newlines + (let [sexpr " +[1 ;dill + 2 ;dall + 3 ;jalla +]" + expected " +[1 ;dill + 2 ;dall +] [3 ;jalla +]" + res (-> sexpr + z/of-string + z/down z/right + pe/split)] + (is (= expected (-> res z/root-string))) + (is (= "2" (-> res z/string))))) + +(deftest split-when-only-one-returns-self + (is (= "[1]" (-> (z/of-string "[1]") + z/down + pe/split + z/root-string))) + (is (= "[1 ;dill\n]" (-> (z/of-string "[1 ;dill\n]") + z/down + pe/split + z/root-string)))) + + +(deftest split-at-pos-when-string + (is (= "(\"Hello \" \"World\")" (-> (z/of-string "(\"Hello World\")") + (pe/split-at-pos {:row 1 :col 9}) + z/root-string)))) + + +(deftest join-simple + (let [res (-> "[1 2] [3 4]" + z/of-string + ;z/down + zz/right + pe/join)] + (is (= "[1 2 3 4]" (-> res z/root-string))) + (is (= "3" (-> res z/string))))) + +(deftest join-with-comments + (let [sexpr " +[[1 2] ; the first stuff + [3 4] ; the second stuff +]" expected " +[[1 2 ; the first stuff + 3 4]; the second stuff +]" + res (-> sexpr + z/of-string + z/down zz/right + pe/join)] + (is (= expected (-> res z/root-string))))) + + +(deftest join-strings + (is (= "(\"Hello World\")" (-> (z/of-string "(\"Hello \" \"World\")") + z/down z/rightmost + pe/join + z/root-string)))) + + +(deftest raise + (is (= "[1 3]" + (-> (z/of-string "[1 [2 3 4]]") + z/down z/right z/down z/right + pe/raise + z/root-string)))) + + +(deftest move-to-prev-flat + (is (= "(+ 2 1)" (-> "(+ 1 2)" + z/of-string + z/down + z/rightmost + pe/move-to-prev + z/root-string)))) + +(deftest move-to-prev-when-prev-is-seq + (is (= "(+ 1 (+ 2 3 4))" (-> "(+ 1 (+ 2 3) 4)" + z/of-string + z/down + z/rightmost + pe/move-to-prev + z/root-string)))) + +(deftest move-to-prev-out-of-seq + (is (= "(+ 1 4 (+ 2 3))" (-> "(+ 1 (+ 2 3) 4)" + z/of-string + z/down + z/rightmost + (move-n pe/move-to-prev 6) + z/root-string)))) diff --git a/src/cljs-lib/test/pez_rewrite_clj/runner.cljs b/src/cljs-lib/test/pez_rewrite_clj/runner.cljs new file mode 100644 index 0000000..fca6ab6 --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/runner.cljs @@ -0,0 +1,15 @@ +(ns pez-rewrite-clj.runner + (:require [doo.runner :refer-macros [doo-tests]] + [pez-rewrite-clj.zip-test] + [pez-rewrite-clj.paredit-test] + [pez-rewrite-clj.node-test] + [pez-rewrite-clj.zip.seqz-test] + [pez-rewrite-clj.zip.findz-test] + [pez-rewrite-clj.zip.editz-test])) + +(doo-tests 'pez-rewrite-clj.zip-test + 'pez-rewrite-clj.paredit-test + 'pez-rewrite-clj.node-test + 'pez-rewrite-clj.zip.seqz-test + 'pez-rewrite-clj.zip.findz-test + 'pez-rewrite-clj.zip.editz-test) diff --git a/src/cljs-lib/test/pez_rewrite_clj/zip/editz_test.cljs b/src/cljs-lib/test/pez_rewrite_clj/zip/editz_test.cljs new file mode 100644 index 0000000..2343319 --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/zip/editz_test.cljs @@ -0,0 +1,14 @@ +(ns pez-rewrite-clj.zip.editz-test + (:require [cljs.test :refer-macros [deftest is testing run-tests]] + [pez-rewrite-clj.zip :as z] + [pez-rewrite-clj.node :as n] + [pez-rewrite-clj.zip.editz :as e])) + + + +(deftest splice + (is (= "[1 2 [3 4]]" (-> "[[1 2] [3 4]]" + z/of-string + z/down + e/splice + z/root-string)))) diff --git a/src/cljs-lib/test/pez_rewrite_clj/zip/findz_test.cljs b/src/cljs-lib/test/pez_rewrite_clj/zip/findz_test.cljs new file mode 100644 index 0000000..6c1ed52 --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/zip/findz_test.cljs @@ -0,0 +1,46 @@ +(ns pez-rewrite-clj.zip.findz-test + (:require [cljs.test :refer-macros [deftest is testing run-tests]] + [pez-rewrite-clj.zip :as z] + [pez-rewrite-clj.node :as n] + [pez-rewrite-clj.zip.findz :as f])) + + + +(deftest find-last-by-pos + (is (= "2" (-> "[1 2 3]" + z/of-string + (f/find-last-by-pos {:row 1 :col 4} (constantly true)) + z/string)))) + +(deftest find-last-by-pos-when-whitespace + (is (= " " (-> "[1 2 3]" + z/of-string + (f/find-last-by-pos {:row 1 :col 3} (constantly true)) + z/string)))) + + +(deftest find-last-by-pos-multiline + (let [sample " +{:a 1 + :b 2}" ] + (is (= ":a" (-> sample + z/of-string + (f/find-last-by-pos {:row 2 :col 2}) + z/string))) + (is (= "1" (-> sample + z/of-string + (f/find-last-by-pos {:row 2 :col 5}) + z/string))))) + +(deftest find-tag-by-pos + (is (= "[4 5 6]" (-> "[1 2 3 [4 5 6]]" + z/of-string + (f/find-tag-by-pos {:row 1 :col 8} :vector) + z/string)))) + + +(deftest find-tag-by-pos-set + (is (= "#{4 5 6}" (-> "[1 2 3 #{4 5 6}]" + z/of-string + (f/find-tag-by-pos {:row 1 :col 10} :set) + z/string)))) diff --git a/src/cljs-lib/test/pez_rewrite_clj/zip/seqz_test.cljs b/src/cljs-lib/test/pez_rewrite_clj/zip/seqz_test.cljs new file mode 100644 index 0000000..8e594ae --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/zip/seqz_test.cljs @@ -0,0 +1,33 @@ +(ns pez-rewrite-clj.zip.seqz-test + (:require [cljs.test :refer-macros [deftest is testing run-tests]] + [pez-rewrite-clj.zip :as z] + [pez-rewrite-clj.node :as n] + [pez-rewrite-clj.zip.seqz :as seqz])) + + + +(deftest check-predicates + (is (-> "[1 2 3]" z/of-string z/vector?)) + (is (-> "{:a 1}" z/of-string z/map?)) + (is (-> "#{1 2}" z/of-string z/set?)) + (is (-> "(+ 2 3)" z/of-string z/list?)) + (is (-> "[1 2]" z/of-string z/seq?))) + +(deftest get-from-map + (is (= 1 (-> "{:a 1}" z/of-string (z/get :a) z/node :value)))) + +(deftest get-from-vector + (is (= 10 (-> "[5 10 15]" z/of-string (z/get 1) z/node :value)))) + +(deftest get-from-vector-index-out-of-bounds + (is (thrown-with-msg? js/Error #"Index out of bounds" + (-> "[5 10 15]" z/of-string (z/get 5) z/node :value)))) + +(deftest map-on-vector + (let [sexpr "[1\n2\n3]" + expected "[5\n6\n7]"] + (is (= expected (->> sexpr z/of-string (z/map #(z/edit % + 4)) z/root-string))))) + + +(deftest assoc-on-map + (is (contains? (-> "{:a 1}" z/of-string (z/assoc :b 2) z/node n/sexpr) :b))) diff --git a/src/cljs-lib/test/pez_rewrite_clj/zip_test.cljs b/src/cljs-lib/test/pez_rewrite_clj/zip_test.cljs new file mode 100644 index 0000000..7f79a74 --- /dev/null +++ b/src/cljs-lib/test/pez_rewrite_clj/zip_test.cljs @@ -0,0 +1,37 @@ +(ns pez-rewrite-clj.zip-test + (:require [cljs.test :refer-macros [deftest is testing run-tests]] + [pez-rewrite-clj.zip :as z] + [pez-rewrite-clj.node :as n])) + + +(deftest of-string-simple-sexpr + (let [sexpr "(+ 1 2)"] + (is (= sexpr (-> sexpr z/of-string z/root-string))))) + + + +(deftest manipulate-sexpr + (let [sexpr " + ^{:dynamic true} (+ 1 1 + (+ 2 2) + (reduce + [1 3 4]))" + expected " + ^{:dynamic true} (+ 1 1 + (+ 2 2) + (reduce + [6 7 [1 2]]))"] + (is (= expected (-> sexpr + z/of-string + (z/find-tag-by-pos {:row 4 :col 19} :vector) + (z/replace [5 6 7]) + (z/append-child [1 2]) + z/down + z/remove + z/root-string))))) + + +(deftest namespaced-keywords + (is (= ":dill" (-> ":dill" z/of-string z/root-string))) + (is (= "::dill" (-> "::dill" z/of-string z/root-string))) + (is (= ":dill/dall" (-> ":dill/dall" z/of-string z/root-string))) + (is (= "::dill/dall" (-> "::dill/dall" z/of-string z/root-string))) + (is (= ":%dill.*" (-> ":%dill.*" z/of-string z/root-string)))) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ad8b5e0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,179 @@ +import * as vscode from 'vscode'; +import { customREPLCommandSnippet } from './evaluate'; +// import { ReplConnectSequence } from './nrepl/connectSequence'; +// import { PrettyPrintingOptions } from './printer'; +// import { parseEdn } from '../out/cljs-lib/cljs-lib'; +import { getProjectConfig } from './state'; +import _ = require('lodash'); +// import { isDefined } from './utilities'; + +// const REPL_FILE_EXT = 'calva-repl'; +const KEYBINDINGS_ENABLED_CONFIG_KEY = 'hy.keybindingsEnabled'; +const KEYBINDINGS_ENABLED_CONTEXT_KEY = 'hy:keybindingsEnabled'; + +// type ReplSessionType = 'clj' | 'cljs'; + +// include the 'file' and 'untitled' to the +// document selector. All other schemes are +// not known and therefore not supported. +const documentSelector = [ + { scheme: 'file', language: 'hy' }, + { scheme: 'jar', language: 'hy' }, + { scheme: 'untitled', language: 'hy' }, +]; + +/** + * Trims EDN alias and profile names from any surrounding whitespace or `:` characters. + * This in order to free the user from having to figure out how the name should be entered. + * @param {string} name + * @return {string} The trimmed name + */ +function _trimAliasName(name: string): string { + return name.replace(/^[\s,:]*/, '').replace(/[\s,:]*$/, ''); +} + +// async function readEdnWorkspaceConfig(uri?: vscode.Uri) { +// try { +// let resolvedUri: vscode.Uri; +// const configPath = state.resolvePath('.calva/config.edn'); + +// if (isDefined(uri)) { +// resolvedUri = uri; +// } else if (isDefined(configPath)) { +// resolvedUri = vscode.Uri.file(configPath); +// } else { +// throw new Error('Expected a uri to be passed in or a config to exist at .calva/config.edn'); +// } +// const data = await vscode.workspace.fs.readFile(resolvedUri); +// return addEdnConfig(new TextDecoder('utf-8').decode(data)); +// } catch (error) { +// return error; +// } +// } + +// function mergeSnippets( +// oldSnippets: customREPLCommandSnippet[], +// newSnippets: customREPLCommandSnippet[] +// ): customREPLCommandSnippet[] { +// return newSnippets.concat( +// _.reject( +// oldSnippets, +// (item) => _.findIndex(newSnippets, (newItem) => item.name === newItem.name) !== -1 +// ) +// ); +// } + +/** + * Saves the EDN config in the state to be merged into the actual vsconfig. + * Currently only `:customREPLCommandSnippets` is supported and the `:snippet` has to be a string. + * @param {string} data a string representation of a clojure map + * @returns an error of one was thrown + */ +// function addEdnConfig(data: string) { +// try { +// const parsed = parseEdn(data); +// const old = getProjectConfig(); + +// state.setProjectConfig({ +// customREPLCommandSnippets: mergeSnippets( +// old?.customREPLCommandSnippets ?? [], +// parsed?.customREPLCommandSnippets ?? [] +// ), +// customREPLHoverSnippets: mergeSnippets( +// old?.customREPLHoverSnippets ?? [], +// parsed?.customREPLHoverSnippets ?? [] +// ), +// }); +// } catch (error) { +// return error; +// } +// } +// const watcher = vscode.workspace.createFileSystemWatcher( +// '**/.calva/**/config.edn', +// false, +// false, +// false +// ); + +// watcher.onDidChange((uri: vscode.Uri) => { +// void readEdnWorkspaceConfig(uri); +// }); + +// TODO find a way to validate the configs +function getConfig() { + const configOptions = vscode.workspace.getConfiguration('hy'); + const pareditOptions = vscode.workspace.getConfiguration('hy.paredit'); + + const w = + configOptions.inspect('customREPLCommandSnippets') + ?.workspaceValue ?? []; + const commands = w.concat( + (getProjectConfig()?.customREPLCommandSnippets as customREPLCommandSnippet[]) ?? [] + ); + const hoverSnippets = ( + configOptions.inspect('customREPLHoverSnippets')?.workspaceValue ?? + [] + ).concat((getProjectConfig()?.customREPLHoverSnippets as customREPLCommandSnippet[]) ?? []); + + return { + format: configOptions.get('formatOnSave'), + evaluate: configOptions.get('evalOnSave'), + test: configOptions.get('testOnSave'), + showDocstringInParameterHelp: configOptions.get('showDocstringInParameterHelp'), + jackInEnv: configOptions.get('jackInEnv'), + jackInDependencyVersions: configOptions.get<{ + JackInDependency: string; + }>('jackInDependencyVersions'), + clojureLspVersion: configOptions.get('clojureLspVersion'), + clojureLspPath: configOptions.get('clojureLspPath'), + openBrowserWhenFigwheelStarted: configOptions.get('openBrowserWhenFigwheelStarted'), + customCljsRepl: configOptions.get('customCljsRepl', null), + // replConnectSequences: configOptions.get('replConnectSequences'), + myLeinProfiles: configOptions.get('myLeinProfiles', []).map(_trimAliasName), + myCljAliases: configOptions.get('myCljAliases', []).map(_trimAliasName), + asyncOutputDestination: configOptions.get('sendAsyncOutputTo'), + customREPLCommandSnippets: configOptions.get( + 'customREPLCommandSnippets', + [] + ), + customREPLCommandSnippetsGlobal: + configOptions.inspect('customREPLCommandSnippets')?.globalValue ?? + [], + customREPLCommandSnippetsWorkspace: commands, + customREPLCommandSnippetsWorkspaceFolder: + configOptions.inspect('customREPLCommandSnippets') + ?.workspaceFolderValue ?? [], + customREPLHoverSnippets: hoverSnippets, + // prettyPrintingOptions: configOptions.get('prettyPrintingOptions'), + evaluationSendCodeToOutputWindow: configOptions.get( + 'evaluationSendCodeToOutputWindow' + ), + enableJSCompletions: configOptions.get('enableJSCompletions'), + autoOpenREPLWindow: configOptions.get('autoOpenREPLWindow'), + autoOpenJackInTerminal: configOptions.get('autoOpenJackInTerminal'), + referencesCodeLensEnabled: configOptions.get('referencesCodeLens.enabled'), + hideReplUi: configOptions.get('hideReplUi'), + strictPreventUnmatchedClosingBracket: pareditOptions.get( + 'strictPreventUnmatchedClosingBracket' + ), + showCalvaSaysOnStart: configOptions.get('showCalvaSaysOnStart'), + jackIn: { + useDeprecatedAliasFlag: configOptions.get('jackIn.useDeprecatedAliasFlag'), + }, + enableClojureLspOnStart: configOptions.get('enableClojureLspOnStart'), + projectRootsSearchExclude: configOptions.get('projectRootsSearchExclude', []), + useLiveShare: configOptions.get('useLiveShare'), + definitionProviderPriority: configOptions.get('definitionProviderPriority'), + }; +} + +export { + // readEdnWorkspaceConfig, + // addEdnConfig, + // REPL_FILE_EXT, + KEYBINDINGS_ENABLED_CONFIG_KEY, + KEYBINDINGS_ENABLED_CONTEXT_KEY, + documentSelector, + // ReplSessionType, + getConfig, +}; diff --git a/src/cursor-doc/cdf-edits/hy-lexer.ts b/src/cursor-doc/cdf-edits/hy-lexer.ts new file mode 100644 index 0000000..09a970e --- /dev/null +++ b/src/cursor-doc/cdf-edits/hy-lexer.ts @@ -0,0 +1,258 @@ +/* eslint-disable no-control-regex */ +/** + * Calva-inspired hy Lexer + * + * 2022-07-06: Borrowed from Calva, original stored in directory above + * + * NB: The lexer tokenizes any combination of clojure quotes, `~`, and `@` prepending a list, symbol, or a literal + * as one token, together with said list, symbol, or literal, even if there is whitespace between the quoting characters. + * All such combos won't actually be accepted by the Clojure Reader, but, hey, we're not writing a Clojure Reader here. 😀 + * See below for the regex used for this. + */ + +// prefixing patterns - TODO: revisit these and see if we can always use the same +// opens ((? ({ type: 'ws' })); +// newlines, we want each one as a token of its own +toplevel.terminal('ws-nl', /(\r|\n|\r\n)/, (l, m) => ({ type: 'ws' })); +// lots of other things are considered whitespace +// https://github.com/sogaiu/tree-sitter-clojure/blob/f8006afc91296b0cdb09bfa04e08a6b3347e5962/grammar.js#L6-L32 +toplevel.terminal( + 'ws-other', + /[\f\u000B\u001C\u001D\u001E\u001F\u2028\u2029\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2008\u2009\u200a\u205f\u3000]+/, + (l, m) => ({ type: 'ws' }) +); +// comments +toplevel.terminal('comment', /;.*/, (l, m) => ({ type: 'comment' })); +// Calva repl prompt, it contains special colon symbols and a hard space +toplevel.terminal( + 'comment', + // eslint-disable-next-line no-irregular-whitespace + /^[^()[\]{},~@`^"\s;]+꞉[^()[\]{},~@`^"\s;]+꞉> /, + (l, m) => ({ type: 'prompt' }) +); + +// current idea for prefixing data reader +// (#[^\(\)\[\]\{\}"_@~\s,]+[\s,]*)* + +// open parens +toplevel.terminal('open', /(```|((?<=(^|[()[\]{}\s,]))['`~#@?^]\s*)*['`~#@?^]*[([{"])/, (l, m) => ({ + type: 'open', +})); + +// close parens +toplevel.terminal('close', /\)|\]|\}/, (l, m) => ({ type: 'close' })); + +// ignores +toplevel.terminal('ignore', /#_/, (l, m) => ({ type: 'ignore' })); + +// literals +toplevel.terminal('lit-quoted-ws', /\\[\n\r\t ]/, (l, m) => ({ type: 'lit' })); +toplevel.terminal('lit-quoted-chars', /\\.?/, (l, m) => ({ type: 'lit' })); +toplevel.terminal('lit-quoted', /\\[^()[\]{}\s;,\\][^()[\]{}\s;,\\]+/, (l, m) => ({ type: 'lit' })); +toplevel.terminal('lit-quoted-brackets', /\\[()[\]{}]/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-symbolic-values', /##[\s,]*(NaN|-?Inf)/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-reserved', /(['`~#]\s*)*(true|false|nil)/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal( + 'lit-integer', + /(['`~#]\s*)*[-+]?(0+|[1-9]+[0-9]*)([rR][0-9a-zA-Z]+|[N])*/, + (l, m) => ({ + type: 'lit', + }) +); +toplevel.terminal( + 'lit-number-sci', + /(['`~#]\s*)*([-+]?(0+[0-9]*|[1-9]+[0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?)M?/, + (l, m) => ({ type: 'lit' }) +); +toplevel.terminal('lit-hex-integer', /(['`~#]\s*)*[-+]?0[xX][0-9a-zA-Z]+/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-octal-integer', /(['`~#]\s*)*[-+]?0[0-9]+[nN]?/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-ratio', /(['`~#]\s*)*[-+]?\d+\/\d+/, (l, m) => ({ + type: 'lit', +})); + +toplevel.terminal('kw', /(['`~^]\s*)*(:[^()[\]{},~@`^"\s;]*)/, (l, m) => ({ + type: 'kw', +})); + +// data readers +toplevel.terminal('reader', /#[^()[\]{}'"_@~\s,;\\]+/, (_l, _m) => ({ + type: 'reader', +})); + +// symbols, allows quite a lot, but can't start with `#_`, anything numeric, or a selection of chars +// 2022-07-07: Removed ` from first capture group to try and support Long Strings +toplevel.terminal( + 'id', + /(['~#^@]\s*)*(((? ({ type: 'id' }) +); + +// Lexer croaks without this catch-all safe +toplevel.terminal('junk', /[\u0000-\uffff]/, (l, m) => ({ type: 'junk' })); + +/** This is inside-string string grammar. It spits out 'close' once it is time to switch back to the 'toplevel' grammar, + * and 'str-inside' for the words in the string. */ +const inString = new LexicalGrammar(); +// end a string +inString.terminal('close', /"/, (l, m) => ({ type: 'close' })); +// still within a string +// 2022-07-07: Trying to make long strings work too +inString.terminal('str-inside', /(?<="| |```)(\\.|[^"\s`]|`(?!`))+/, (l, m) => ({ + type: 'str-inside', +})); +// whitespace, excluding newlines +inString.terminal('ws', /[\t ]+/, (l, m) => ({ type: 'ws' })); +// newlines, we want each one as a token of its own +inString.terminal('ws-nl', /(\r?\n)/, (l, m) => ({ type: 'ws' })); + +// Lexer can croak on funny data without this catch-all safe: see https://github.com/BetterThanTomorrow/calva/issues/659 +inString.terminal('junk', /[\u0000-\uffff]/, (l, m) => ({ type: 'junk' })); + +/** this is inside-long-string string grammar. It spits out 'close' once it is time to switch back to the 'toplevel grammar, + * and 'long-str-inside' for the words in the string. */ +const inLongString = new LexicalGrammar(); + +inLongString.terminal('comment', /#.*$/, (l, m) => ({ type: 'comment' })); + +inLongString.terminal('close', /```/, (l, m) => ({ type : 'close' })); + +inLongString.terminal('long-str-inside', /(?<="| |```)(\\.|[^"\s`]|`(?!`))+/, (l, m) => ({ + type: 'long-str-inside', + })); + +inLongString.terminal('open', /(```|((?<=(^|[()[\]{}\s,]))['`~#@?^]\s*)*['`~#@?^]*[([{"])/, (l, m) => ({ + type: 'open', +})); + +// whitespace, excluding newlines +inLongString.terminal('ws', /[\t ]+/, (l, m) => ({ type: 'ws' })); +// newlines, we want each one as a token of its own +inLongString.terminal('ws-nl', /(\r?\n)/, (l, m) => ({ type: 'ws' })); + +// Lexer can croak on funny data without this catch-all safe: see https://github.com/BetterThanTomorrow/calva/issues/659 +inLongString.terminal('junk', /[\u0000-\uffff]/, (l, m) => ({ type: 'junk' })); + +/** + * The state of the scanner. + * We only really need to know if we're inside a string or not. + */ +export interface ScannerState { + /** Are we scanning inside a string? If so use inString grammar, otherwise use toplevel. */ + inLongString: boolean; + inString: boolean; +} + +/** + * A Clojure(Script) lexical analyser. + * Takes a line of text and a start state, and returns an array of Token, updating its internal state. + */ +export class Scanner { + state: ScannerState = { inLongString: false, + inString: false }; + + constructor(private maxLength: number) {} + + processLine(line: string, state: ScannerState = this.state) { + const tks: Token[] = []; + this.state = state; + let lex = (this.state.inString ? inString : (this.state.inLongString ? inLongString : toplevel)).lex(line, this.maxLength); + let tk: LexerToken; + do { + tk = lex.scan(); + if (tk) { + const oldpos = lex.position; + if (tk.raw.match(/(```|[~`'@#]*")$/)) { + switch (tk.type) { + case 'open': // string started, switch to inString. + this.state = (tk.raw.match(/```$/) ? { ...this.state, inLongString: true } : { ...this.state, inString: true }); + lex = (tk.raw.match(/```$/) ? inLongString : inString).lex(line, this.maxLength); + lex.position = oldpos; + break; + case 'close': + // string ended, switch back to toplevel + if (tk.raw.match(/```$/)) { + this.state = { ...this.state, inLongString: false } + lex = toplevel.lex(line, this.maxLength); + lex.position = oldpos; + break; + } else if (this.state.inLongString) { + this.state = { ...this.state, inString: false }; + lex = inLongString.lex(line, this.maxLength); + lex.position = oldpos; + break; + } else if (this.state.inString) { + this.state = { ...this.state, inString: false }; + lex = toplevel.lex(line, this.maxLength); + lex.position = oldpos; + break; + } else { + break; + } + } + } + tks.push({ ...tk, state: this.state }); + } + } while (tk); + // Uncomment to observe the lexer's output + // console.log("cursor-doc/cdf-edits/hy-lexer.ts/Scanner/processLine ", tks); + + // insert a sentinel EOL value, this allows us to simplify TokenCaret's implementation. + tks.push({ + type: 'eol', + raw: '\n', + offset: line.length, + state: this.state, + }); + return tks; + } +} diff --git a/src/cursor-doc/clojure-lexer.ts b/src/cursor-doc/clojure-lexer.ts new file mode 100644 index 0000000..6ed535e --- /dev/null +++ b/src/cursor-doc/clojure-lexer.ts @@ -0,0 +1,209 @@ +/* eslint-disable no-control-regex */ +/** + * Calva Clojure Lexer + * + * NB: The lexer tokenizes any combination of clojure quotes, `~`, and `@` prepending a list, symbol, or a literal + * as one token, together with said list, symbol, or literal, even if there is whitespace between the quoting characters. + * All such combos won't actually be accepted by the Clojure Reader, but, hey, we're not writing a Clojure Reader here. 😀 + * See below for the regex used for this. + */ + +// prefixing patterns - TODO: revisit these and see if we can always use the same +// opens ((? ({ type: 'ws' })); +// newlines, we want each one as a token of its own +toplevel.terminal('ws-nl', /(\r|\n|\r\n)/, (l, m) => ({ type: 'ws' })); +// lots of other things are considered whitespace +// https://github.com/sogaiu/tree-sitter-clojure/blob/f8006afc91296b0cdb09bfa04e08a6b3347e5962/grammar.js#L6-L32 +toplevel.terminal( + 'ws-other', + /[\f\u000B\u001C\u001D\u001E\u001F\u2028\u2029\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2008\u2009\u200a\u205f\u3000]+/, + (l, m) => ({ type: 'ws' }) +); +// comments +toplevel.terminal('comment', /;.*/, (l, m) => ({ type: 'comment' })); +// Calva repl prompt, it contains special colon symbols and a hard space +toplevel.terminal( + 'comment', + // eslint-disable-next-line no-irregular-whitespace + /^[^()[\]{},~@`^"\s;]+꞉[^()[\]{},~@`^"\s;]+꞉> /, + (l, m) => ({ type: 'prompt' }) +); + +// current idea for prefixing data reader +// (#[^\(\)\[\]\{\}"_@~\s,]+[\s,]*)* + +// open parens +toplevel.terminal('open', /((?<=(^|[()[\]{}\s,]))['`~#@?^]\s*)*['`~#@?^]*[([{"]/, (l, m) => ({ + type: 'open', +})); + +// close parens +toplevel.terminal('close', /\)|\]|\}/, (l, m) => ({ type: 'close' })); + +// ignores +toplevel.terminal('ignore', /#_/, (l, m) => ({ type: 'ignore' })); + +// literals +toplevel.terminal('lit-quoted-ws', /\\[\n\r\t ]/, (l, m) => ({ type: 'lit' })); +toplevel.terminal('lit-quoted-chars', /\\.?/, (l, m) => ({ type: 'lit' })); +toplevel.terminal('lit-quoted', /\\[^()[\]{}\s;,\\][^()[\]{}\s;,\\]+/, (l, m) => ({ type: 'lit' })); +toplevel.terminal('lit-quoted-brackets', /\\[()[\]{}]/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-symbolic-values', /##[\s,]*(NaN|-?Inf)/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-reserved', /(['`~#]\s*)*(true|false|nil)/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal( + 'lit-integer', + /(['`~#]\s*)*[-+]?(0+|[1-9]+[0-9]*)([rR][0-9a-zA-Z]+|[N])*/, + (l, m) => ({ + type: 'lit', + }) +); +toplevel.terminal( + 'lit-number-sci', + /(['`~#]\s*)*([-+]?(0+[0-9]*|[1-9]+[0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?)M?/, + (l, m) => ({ type: 'lit' }) +); +toplevel.terminal('lit-hex-integer', /(['`~#]\s*)*[-+]?0[xX][0-9a-zA-Z]+/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-octal-integer', /(['`~#]\s*)*[-+]?0[0-9]+[nN]?/, (l, m) => ({ + type: 'lit', +})); +toplevel.terminal('lit-ratio', /(['`~#]\s*)*[-+]?\d+\/\d+/, (l, m) => ({ + type: 'lit', +})); + +toplevel.terminal('kw', /(['`~^]\s*)*(:[^()[\]{},~@`^"\s;]*)/, (l, m) => ({ + type: 'kw', +})); + +// data readers +toplevel.terminal('reader', /#[^()[\]{}'"_@~\s,;\\]+/, (_l, _m) => ({ + type: 'reader', +})); + +// symbols, allows quite a lot, but can't start with `#_`, anything numeric, or a selection of chars +toplevel.terminal( + 'id', + /(['`~#^@]\s*)*(((? ({ type: 'id' }) +); + +// Lexer croaks without this catch-all safe +toplevel.terminal('junk', /[\u0000-\uffff]/, (l, m) => ({ type: 'junk' })); + +/** This is inside-string string grammar. It spits out 'close' once it is time to switch back to the 'toplevel' grammar, + * and 'str-inside' for the words in the string. */ +const inString = new LexicalGrammar(); +// end a string +inString.terminal('close', /"/, (l, m) => ({ type: 'close' })); +// still within a string +inString.terminal('str-inside', /(\\.|[^"\s])+/, (l, m) => ({ + type: 'str-inside', +})); +// whitespace, excluding newlines +inString.terminal('ws', /[\t ]+/, (l, m) => ({ type: 'ws' })); +// newlines, we want each one as a token of its own +inString.terminal('ws-nl', /(\r?\n)/, (l, m) => ({ type: 'ws' })); + +// Lexer can croak on funny data without this catch-all safe: see https://github.com/BetterThanTomorrow/calva/issues/659 +inString.terminal('junk', /[\u0000-\uffff]/, (l, m) => ({ type: 'junk' })); + +/** + * The state of the scanner. + * We only really need to know if we're inside a string or not. + */ +export interface ScannerState { + /** Are we scanning inside a string? If so use inString grammar, otherwise use toplevel. */ + inString: boolean; +} + +/** + * A Clojure(Script) lexical analyser. + * Takes a line of text and a start state, and returns an array of Token, updating its internal state. + */ +export class Scanner { + state: ScannerState = { inString: false }; + + constructor(private maxLength: number) {} + + processLine(line: string, state: ScannerState = this.state) { + const tks: Token[] = []; + this.state = state; + let lex = (this.state.inString ? inString : toplevel).lex(line, this.maxLength); + let tk: LexerToken; + do { + tk = lex.scan(); + if (tk) { + const oldpos = lex.position; + if (tk.raw.match(/[~`'@#]*"$/)) { + switch (tk.type) { + case 'open': // string started, switch to inString. + this.state = { ...this.state, inString: true }; + lex = inString.lex(line, this.maxLength); + lex.position = oldpos; + break; + case 'close': + // string ended, switch back to toplevel + this.state = { ...this.state, inString: false }; + lex = toplevel.lex(line, this.maxLength); + lex.position = oldpos; + break; + } + } + tks.push({ ...tk, state: this.state }); + } + } while (tk); + // insert a sentinel EOL value, this allows us to simplify TokenCaret's implementation. + tks.push({ + type: 'eol', + raw: '\n', + offset: line.length, + state: this.state, + }); + return tks; + } +} diff --git a/src/cursor-doc/cursor-context.ts b/src/cursor-doc/cursor-context.ts new file mode 100644 index 0000000..1f8771d --- /dev/null +++ b/src/cursor-doc/cursor-context.ts @@ -0,0 +1,93 @@ +import { EditableDocument } from './model'; + +export const allCursorContexts = [ + 'hy:cursorInString', + 'hy:cursorInComment', + 'hy:cursorAtStartOfLine', + 'hy:cursorAtEndOfLine', + 'hy:cursorBeforeComment', + 'hy:cursorAfterComment', +] as const; + +export type CursorContext = typeof allCursorContexts[number]; + +/** + * Returns true if documentOffset is either at the first char of the token under the cursor, or + * in the whitespace between the token and the first preceding EOL, otherwise false + */ +export function isAtLineStartInclWS(doc: EditableDocument, offset = doc.selection.active) { + const tokenCursor = doc.getTokenCursor(offset); + let startOfLine = false; + // only at start if we're in ws, or at the 1st char of a non-ws sexp + if (tokenCursor.getToken().type === 'ws' || tokenCursor.offsetStart >= offset) { + while (tokenCursor.getPrevToken().type === 'ws') { + tokenCursor.previous(); + } + startOfLine = tokenCursor.getPrevToken().type === 'eol'; + } + + return startOfLine; +} + +/** + * Returns true if position is after the last char of the last lisp token on the line, including + * any trailing whitespace or EOL, otherwise false + */ +export function isAtLineEndInclWS(doc: EditableDocument, offset = doc.selection.active) { + const tokenCursor = doc.getTokenCursor(offset); + if (tokenCursor.getToken().type === 'eol') { + return true; + } + if (tokenCursor.getPrevToken().type === 'eol' && tokenCursor.getToken().type !== 'ws') { + return false; + } + if (tokenCursor.getToken().type === 'ws') { + tokenCursor.next(); + if (tokenCursor.getToken().type !== 'eol') { + return false; + } + tokenCursor.previous(); + } + tokenCursor.forwardWhitespace(); + const textFromOffset = doc.model.getText(offset, tokenCursor.offsetStart); + if (textFromOffset.match(/^\s+/)) { + return true; + } + return false; +} + +export function determineContexts( + doc: EditableDocument, + offset = doc.selection.active +): CursorContext[] { + const tokenCursor = doc.getTokenCursor(offset); + const contexts: CursorContext[] = []; + + if (isAtLineStartInclWS(doc)) { + contexts.push('hy:cursorAtStartOfLine'); + } else if (isAtLineEndInclWS(doc)) { + contexts.push('hy:cursorAtEndOfLine'); + } + + if (tokenCursor.withinString()) { + contexts.push('hy:cursorInString'); + } else if (tokenCursor.withinComment()) { + contexts.push('hy:cursorInComment'); + } + + // Compound contexts + if (contexts.includes('hy:cursorInComment')) { + if (contexts.includes('hy:cursorAtEndOfLine')) { + tokenCursor.forwardWhitespace(false); + if (tokenCursor.getToken().type != 'comment') { + contexts.push('hy:cursorAfterComment'); + } + } else if (contexts.includes('hy:cursorAtStartOfLine')) { + tokenCursor.backwardWhitespace(false); + if (tokenCursor.getPrevToken().type != 'comment') { + contexts.push('hy:cursorBeforeComment'); + } + } + } + return contexts; +} diff --git a/src/cursor-doc/indent.ts b/src/cursor-doc/indent.ts new file mode 100644 index 0000000..a9f0d81 --- /dev/null +++ b/src/cursor-doc/indent.ts @@ -0,0 +1,202 @@ +import { EditableModel } from './model'; +import * as _ from 'lodash'; +import { InlineCompletionTriggerKind } from 'vscode'; + +const whitespace = new Set(['ws', 'comment', 'eol']); + +export type IndentRule = ['block', number] | ['inner', number] | ['inner', number, number]; + +export type IndentRules = { + [id: string]: IndentRule[]; +}; + +const indentRules: IndentRules = { + '#"^\\w"': [['inner', 0]], +}; + +/** + * The information about an enclosing s-expr, returned by collectIndents + */ +export interface IndentInformation { + /** The first token in the expression (after the open paren/bracket etc.), as a raw string */ + first: string; + + /** The indent immediately after the open paren/bracket etc */ + startIndent: number; + + /** If there is a second token on the same line as the first token, the indent for that token */ + firstItemIdent: number; + + /** The applicable indent rules for this IndentInformation, local only. */ + rules: IndentRule[]; + + /** The index at which the cursor (or the sexpr containing the cursor at this level) is in the expression. */ + argPos: number; + + /** The number of expressions on the first line of this expression. */ + exprsOnLine: number; +} + +/** + * Analyses the text before position in the document, and returns a list of enclosing expression information with + * various indent information, for use with getIndent() + * + * @param document The document to analyse + * @param position The position (as [row, col] into the document to analyse from) + * @param maxDepth The maximum depth upwards from the expression to search. + * @param maxLines The maximum number of lines above the position to search until we bail with an imprecise answer. + */ +export function collectIndents( + document: EditableModel, + offset: number, + config: any, + maxDepth: number = 3, + maxLines: number = 20 +): IndentInformation[] { + const cursor = document.getTokenCursor(offset); + cursor.backwardWhitespace(); + let argPos = 0; + const startLine = cursor.line; + let exprsOnLine = 0; + let lastLine = cursor.line; + let lastIndent = 0; + const indents: IndentInformation[] = []; + const rules = config['cljfmt-options']['indents']; + do { + // console.log("If not cursor.backwardSexp, go into If block ", !cursor.backwardSexp()) + if (!cursor.backwardSexp()) { + // this needs some work.. + // console.log("Set prevToken to ", cursor.getPrevToken()) + const prevToken = cursor.getPrevToken(); + if (prevToken.type == 'open' && prevToken.offset <= 1) { + maxDepth = 0; // treat an sexpr starting on line 0 sensibly. + } + // skip past the first item and record the indent of the first item on the same line if there is one. + const nextCursor = cursor.clone(); + nextCursor.forwardSexp(); + nextCursor.forwardWhitespace(); + + // if the first item of this list is a a function, and the second item is on the same line, indent to that second item. otherwise indent to the open paren. + const isList = prevToken.type === 'open' && prevToken.raw.endsWith('('); + const firstItemIdent = + ['id', 'kw'].includes(cursor.getToken().type) && + nextCursor.line == cursor.line && + !nextCursor.atEnd() && + isList + ? nextCursor.rowCol[1] + : cursor.rowCol[1]; + + // console.log("firstItemIdent ", firstItemIdent); + + const token = cursor.getToken().raw; + const startIndent = cursor.rowCol[1]; + if (!cursor.backwardUpList()) { + break; + } + + const pattern = + isList && + _.find(_.keys(rules), (pattern) => pattern === token || testCljRe(pattern, token)); + const indentRule = pattern ? rules[pattern] : []; + indents.unshift({ + first: token, + rules: indentRule, + argPos, + exprsOnLine, + startIndent, + firstItemIdent, + }); + argPos = 0; + exprsOnLine = 1; + } + + if (cursor.line != lastLine) { + const head = cursor.clone(); + head.forwardSexp(); + head.forwardWhitespace(); + if (!head.atEnd()) { + lastIndent = head.rowCol[1]; + exprsOnLine = 0; + lastLine = cursor.line; + } + } + + if (whitespace.has(cursor.getPrevToken().type)) { + argPos++; + exprsOnLine++; + } + } while ( + !cursor.atStart() && + Math.abs(startLine - cursor.line) < maxLines && + indents.length < maxDepth + ); + if (!indents.length) { + indents.push({ + argPos: 0, + first: null, + rules: [], + exprsOnLine: 0, + startIndent: lastIndent >= 0 ? lastIndent : 0, + firstItemIdent: lastIndent >= 0 ? lastIndent : 0, + }); + } + // console.log("src/cursor-doc/indent.ts/collectIndents ", indents); + return indents; +} + +const testCljRe = (re, str) => { + const matches = re.match(/^#"(.*)"$/); + return matches && RegExp(matches[1]).test(str); +}; + +/** Returns the expected newline indent for the given position, in characters. */ +export function getIndent(document: EditableModel, offset: number, config?: any): number { + if (!config) { + config = { + 'cljfmt-options': { + indents: indentRules, + }, + }; + } + const state = collectIndents(document, offset, config); + // now find applicable indent rules + let indent = -1; + const thisBlock = state[state.length - 1]; + if (!state.length) { + return 0; + } + + for (let pos = state.length - 1; pos >= 0; pos--) { + for (const rule of state[pos].rules) { + if (rule[0] == 'inner') { + if (pos + rule[1] == state.length - 1) { + if (rule.length == 3) { + if (rule[2] > thisBlock.argPos) { + indent = thisBlock.startIndent + 1; + } + } else { + indent = thisBlock.startIndent + 1; + } + } + } else if (rule[0] == 'block' && pos == state.length - 1) { + if (thisBlock.exprsOnLine <= rule[1]) { + if (thisBlock.argPos >= rule[1]) { + indent = thisBlock.startIndent + 1; + } + } else { + indent = thisBlock.firstItemIdent; + } + } + } + } + + if (indent == -1) { + // no indentation styles applied, so use default style. + if (thisBlock.exprsOnLine > 0) { + indent = thisBlock.firstItemIdent; + } else { + indent = thisBlock.startIndent; + } + } + return indent; +} diff --git a/src/cursor-doc/lexer.ts b/src/cursor-doc/lexer.ts new file mode 100644 index 0000000..4da61f1 --- /dev/null +++ b/src/cursor-doc/lexer.ts @@ -0,0 +1,106 @@ +/** + * A Lexical analyser + * @module lexer + */ + +/** + * The base Token class. Contains the token type, + * the raw string of the token, and the offset into the input line. + */ +export interface Token { + type: string; + raw: string; + offset: number; +} + +/** + * A Lexical rule for a terminal. Consists of a RegExp and an action. + */ +export interface Rule { + name: string; + r: RegExp; + fn: (Lexer, RegExpExecArray) => any; +} + +/** + * A Lexer instance, parsing a given file. Usually you should use a LexicalGrammar to + * create one of these. + * + * @class + * @param {string} source the source code to parse + * @param rules the rules of this lexer. + */ + +export class Lexer { + position: number = 0; + constructor(public source: string, public rules: Rule[], private maxLength) {} + + /** Returns the next token in this lexer, or null if at the end. If the match fails, throws an Error. */ + scan(): Token { + let token = null, + length = 0; + if (this.position < this.source.length) { + if (this.source !== undefined && this.source.length < this.maxLength) { + // TODO: Consider using vscode setting for tokenisation max length + this.rules.forEach((rule) => { + rule.r.lastIndex = this.position; + const x = rule.r.exec(this.source); + if (x && x[0].length > length && this.position + x[0].length == rule.r.lastIndex) { + token = rule.fn(this, x); + token.offset = this.position; + token.raw = x[0]; + length = x[0].length; + } + }); + } else { + length = this.source.length; + token = { + type: 'too-long-line', + offset: this.position, + raw: this.source, + }; + } + } + this.position += length; + if (token == null) { + if (this.position == this.source.length) { + return null; + } + throw new Error( + 'Unexpected character at ' + this.position + ': ' + JSON.stringify(this.source) + ); + } + return token; + } +} + +/** + * A lexical grammar- factory for lexer instances. + * @class + */ +export class LexicalGrammar { + rules: Rule[] = []; + + /** + * Defines a terminal with the given pattern and constructor. + * @param {string | RegExp} pattern the pattern this terminal must match. + * @param {function(Array): Object} fn returns a lexical token representing + * this terminal. An additional "offset" property containing the token source position + * will also be added, as well as a "raw" property, containing the raw string match. + */ + terminal(name: string, pattern: string | RegExp, fn: (T, RegExpExecArray) => any): void { + this.rules.push({ + name, + // This is b/c the RegExp constructor seems to not like our union type (unknown reasons why) + r: pattern instanceof RegExp ? new RegExp(pattern, 'g') : new RegExp(pattern, 'g'), + fn: fn, + }); + } + + /** + * Create a Lexer for the given input. + */ + lex(source: string, maxLength): Lexer { + return new Lexer(source, this.rules, maxLength); + } +} diff --git a/src/cursor-doc/model.ts b/src/cursor-doc/model.ts new file mode 100644 index 0000000..3f57b55 --- /dev/null +++ b/src/cursor-doc/model.ts @@ -0,0 +1,604 @@ +import { Scanner, Token, ScannerState } from './cdf-edits/hy-lexer'; +import { LispTokenCursor } from './token-cursor'; +// import { deepEqual as equal } from '../util/object'; +import { isUndefined } from 'lodash'; + +let scanner: Scanner; + +function equal(x: any, y: any): boolean { + if (x == y) { + return true; + } + if (x instanceof Array && y instanceof Array) { + if (x.length == y.length) { + for (let i = 0; i < x.length; i++) { + if (!equal(x[i], y[i])) { + return false; + } + } + return true; + } else { + return false; + } + } else if ( + !(x instanceof Array) && + !(y instanceof Array) && + x instanceof Object && + y instanceof Object + ) { + for (const f in x) { + if (!equal(x[f], y[f])) { + return false; + } + } + for (const f in y) { + if (!Object.prototype.hasOwnProperty.call(x, f)) { + return false; + } + } + return true; + } + return false; +} + +export function initScanner(maxLength: number) { + scanner = new Scanner(maxLength); +} + +export class TextLine { + tokens: Token[] = []; + text: string; + endState: ScannerState; + constructor(text: string, public startState: ScannerState) { + this.text = text; + this.tokens = scanner.processLine(text); + this.endState = { ...scanner.state }; + } + + processLine(oldState: any) { + this.startState = { ...oldState }; + this.tokens = scanner.processLine(this.text, oldState); + this.endState = { ...scanner.state }; + } +} + +export type ModelEditFunction = 'insertString' | 'changeRange' | 'deleteRange'; + +export class ModelEdit { + constructor(public editFn: ModelEditFunction, public args: any[]) {} +} + +/** + * Naming notes for Model Selections: + * `anchor`, the start of a selection, can be left or right of, or the same as the end of the selection (active) + * `active`, the end of a selection, where the caret is, can be left or right of, or the same as the start of the selection (anchor) + * `left`, the smallest of `anchor` and `active` + * `right`, the largest of `anchor` and `active` + * `backward`, movement towards the left + * `forward`, movement towards the right + * `up`, movement out of lists + * `down`, movement into lists + * + * This will be in line with vscode when it comes to anchor/active, but introduce our own terminology for the span of the selection. It will also keep the tradition of paredit with backward/forward and up/down. + */ + +export class ModelEditSelection { + private _anchor: number; + private _active: number; + + constructor(anchor: number, active?: number) { + this._anchor = anchor; + if (active !== undefined) { + this._active = active; + } else { + this._active = anchor; + } + } + + get anchor() { + return this._anchor; + } + + set anchor(v: number) { + this._anchor = v; + } + + get active() { + return this._active; + } + + set active(v: number) { + this._active = v; + } + + clone() { + return new ModelEditSelection(this._anchor, this._active); + } +} + +export type ModelEditOptions = { + undoStopBefore?: boolean; + formatDepth?: number; + skipFormat?: boolean; + selection?: ModelEditSelection; +}; + +export interface EditableModel { + readonly lineEndingLength: number; + + /** + * Performs a model edit batch. + * For some EditableModel's these are performed as one atomic set of edits. + * @param edits + */ + edit: (edits: ModelEdit[], options: ModelEditOptions) => Thenable; + + getText: (start: number, end: number, mustBeWithin?: boolean) => string; + getLineText: (line: number) => string; + getOffsetForLine: (line: number) => number; + getTokenCursor: (offset: number, previous?: boolean) => LispTokenCursor; +} + +export interface EditableDocument { + selection: ModelEditSelection; + model: EditableModel; + selectionStack: ModelEditSelection[]; + getTokenCursor: (offset?: number, previous?: boolean) => LispTokenCursor; + insertString: (text: string) => void; + getSelectionText: () => string; + delete: () => Thenable; + backspace: () => Thenable; +} + +/** The underlying model for the REPL readline. */ +export class LineInputModel implements EditableModel { + /** How many characters in the line endings of the text of this model? */ + constructor(readonly lineEndingLength: number = 1, private document?: EditableDocument) {} + + /** The input lines. */ + lines: TextLine[] = [new TextLine('', this.getStateForLine(0))]; + + /** Lines whose text has changed. */ + changedLines: Set = new Set(); + + /** Lines which must be inserted. */ + insertedLines: Set<[number, number]> = new Set(); + + /** Lines which must be deleted. */ + deletedLines: Set<[number, number]> = new Set(); + + /** When set, insertString and deleteRange will be added to the undo history. */ + recordingUndo: boolean = false; + + /** Lines which must be re-lexed. */ + dirtyLines: number[] = []; + + private updateLines(start: number, deleted: number, inserted: number) { + const delta = inserted - deleted; + + this.dirtyLines = this.dirtyLines + .filter((x) => x < start || x >= start + deleted) + .map((x) => (x >= start ? x + delta : x)); + + this.changedLines = new Set( + Array.from(this.changedLines) + .map((x) => { + if (x > start && x < start + deleted) { + return null; + } + if (x >= start) { + return x + delta; + } + return x; + }) + .filter((x) => x !== null) + ); + + this.insertedLines = new Set( + Array.from(this.insertedLines) + .map((x): [number, number] => { + const [a, b] = x; + if (a > start && a < start + deleted) { + return null; + } + if (a >= start) { + return [a + delta, b]; + } + return [a, b]; + }) + .filter((x) => x !== null) + ); + + this.deletedLines = new Set( + Array.from(this.deletedLines) + .map((x): [number, number] => { + const [a, b] = x; + if (a > start && a < start + deleted) { + return null; + } + if (a >= start) { + return [a + delta, b]; + } + return [a, b]; + }) + .filter((x) => x !== null) + ); + } + + private deleteLines(start: number, count: number) { + if (count == 0) { + return; + } + this.updateLines(start, count, 0); + this.deletedLines.add([start, count]); + } + + private insertLines(start: number, count: number) { + this.updateLines(start, 0, count); + this.insertedLines.add([start, count]); + } + + /** + * Mark a line as needing to be re-lexed. + * + * @param idx the index of the line which needs re-lexing (0-based) + */ + private markDirty(idx: number) { + if (idx >= 0 && idx < this.lines.length && this.dirtyLines.indexOf(idx) == -1) { + this.dirtyLines.push(idx); + } + } + + /** + * Re-lexes all lines marked dirty, cascading onto the lines below if the end state for this line has + * changed. + */ + flushChanges() { + if (!this.dirtyLines.length) { + return; + } + const seen = new Set(); + this.dirtyLines.sort(); + while (this.dirtyLines.length) { + let nextIdx = this.dirtyLines.shift(); + if (seen.has(nextIdx)) { + continue; + } // already processed. + let prevState = this.getStateForLine(nextIdx); + do { + seen.add(nextIdx); + this.changedLines.add(nextIdx); + this.lines[nextIdx].processLine(prevState); + prevState = this.lines[nextIdx].endState; + } while (this.lines[++nextIdx] && !equal(this.lines[nextIdx].startState, prevState)); + } + } + + /** + * Returns the character offset in the model to the start of a given line. + * + * @param line the line who's offset will be returned. + */ + getOffsetForLine(line: number) { + let max = 0; + for (let i = 0; i < line; i++) { + max += this.lines[i].text.length + this.lineEndingLength; + } + return max; + } + + /** + * Returns the text of the given line + * + * @param line the line to get the text of + */ + getLineText(line: number): string { + return this.lines[line].text; + } + + /** + * Returns the text between start and end as a string. These may be in any order. + * + * @param start the start offset in the text range + * @param end the end offset in the text range + * @param mustBeWithin if the start or end are outside the document, returns "" + */ + getText(start: number, end: number, mustBeWithin = false): string { + if (start == end) { + return ''; + } + if (mustBeWithin && (Math.min(start, end) < 0 || Math.max(start, end) > this.maxOffset)) { + return ''; + } + const st = this.getRowCol(Math.min(start, end)); + const en = this.getRowCol(Math.max(start, end)); + + const lines: string[] = []; + if (st[0] == en[0]) { + lines[0] = this.lines[st[0]].text.substring(st[1], en[1]); + } else { + lines[0] = this.lines[st[0]].text.substring(st[1]); + } + for (let i = st[0] + 1; i < en[0]; i++) { + lines.push(this.lines[i].text); + } + if (st[0] != en[0]) { + lines.push(this.lines[en[0]].text.substring(0, en[1])); + } + return lines.join('\n'); + } + + /** + * Returns the row and column for a given text offset in this model. + */ + getRowCol(offset: number): [number, number] { + for (let i = 0; i < this.lines.length; i++) { + if (offset > this.lines[i].text.length) { + offset -= this.lines[i].text.length + this.lineEndingLength; + } else { + return [i, offset]; + } + } + return [this.lines.length - 1, this.lines[this.lines.length - 1].text.length]; + } + + /** + * Returns the start and end offset of the word found for the given offset in + * the model. + * + * @param offset The offset in the line model. + * @returns [number, number] The start and the index of the word in the model. + */ + getWordSelection(offset: number): [number, number] { + const stopChars = [' ', '"', ';', '.', '(', ')', '[', ']', '{', '}', '\t', '\n', '\r'], + [row, column] = this.getRowCol(offset), + text = this.lines[row].text; + + if (text && text.length > 1 && column < text.length && column >= 0) { + if (stopChars.includes(text[column])) { + return [offset, offset]; + } + let stopIdx = column; + let startIdx = column; + for (let i = column; i >= 0; i--) { + if (stopChars.includes(text[i])) { + break; + } + startIdx = i; + } + for (let j = column; j < text.length; j++) { + if (stopChars.includes(text[j])) { + break; + } + stopIdx = j; + } + return [offset - (column - startIdx), offset + (stopIdx - column) + 1]; + } + return [offset, offset]; + } + + /** + * Returns the initial lexer state for a given line. + * Line 0 is always { inString: false }, all lines below are equivalent to their previous line's startState. + * + * @param line the line to retrieve the lexer state. + */ + private getStateForLine(line: number): ScannerState { + return line == 0 ? { inString: false, inLongString: false } : { ...this.lines[line - 1].endState }; + } + + /** + * Performs a model edit batch. + * Doesn't need to be atomic in the LineInputModel. + * @param edits + */ + edit(edits: ModelEdit[], options: ModelEditOptions): Thenable { + return new Promise((resolve, reject) => { + for (const edit of edits) { + switch (edit.editFn) { + case 'insertString': { + const fn = this.insertString; + this.insertString(...(edit.args.slice(0, 4) as Parameters)); + break; + } + case 'changeRange': { + const fn = this.changeRange; + this.changeRange(...(edit.args.slice(0, 5) as Parameters)); + break; + } + case 'deleteRange': { + const fn = this.deleteRange; + this.deleteRange(...(edit.args.slice(0, 5) as Parameters)); + break; + } + default: + break; + } + } + if (this.document && options.selection) { + this.document.selection = options.selection; + } + resolve(true); + }); + } + + /** + * Changes the model. Deletes any text between `start` and `end`, and the inserts `text`. + * + * If provided, `oldSelection` and `newSelection` are used to manage the cursor positioning for undo support. + * + * @param start the start offset in the range to delete + * @param end the end offset in the range to delete + * @param text the new text to insert + * @param oldSelection the old selection + * @param newSelection the new selection + */ + private changeRange( + start: number, + end: number, + text: string, + oldSelection?: [number, number], + newSelection?: [number, number] + ) { + const t1 = new Date(); + + const startPos = Math.min(start, end); + const endPos = Math.max(start, end); + const deletedText = this.recordingUndo ? this.getText(startPos, endPos) : ''; + const [startLine, startCol] = this.getRowCol(startPos); + const [endLine, endCol] = this.getRowCol(endPos); + // extract the lines we will replace + const replaceLines = text.split(/\r\n|\n/); + + // the left side of the line unaffected by the edit. + const left = this.lines[startLine].text.substr(0, startCol); + + // the right side of the line unaffected by the edit. + const right = this.lines[endLine].text.substr(endCol); + + const items: TextLine[] = []; + + // initialize the lexer state - the first line is definitely not in a string, otherwise copy the + // end state of the previous line before the edit + const state = this.getStateForLine(startLine); + const currentLength = endLine - startLine + 1; + + if (replaceLines.length == 1) { + // trivial single line edit + items.push(new TextLine(left + replaceLines[0] + right, state)); + } else { + // multi line edit. + items.push(new TextLine(left + replaceLines[0], state)); + for (let i = 1; i < replaceLines.length - 1; i++) { + items.push(new TextLine(replaceLines[i], scanner.state)); + } + items.push(new TextLine(replaceLines[replaceLines.length - 1] + right, scanner.state)); + } + + if (currentLength > replaceLines.length) { + // shrink the lines + this.deleteLines(startLine + replaceLines.length, currentLength - replaceLines.length); + } else if (currentLength < replaceLines.length) { + // extend the lines + this.insertLines(endLine, replaceLines.length - currentLength); + } + + // now splice in our edited lines + this.lines.splice(startLine, endLine - startLine + 1, ...items); + + // set the changed and dirty marker + for (let i = 0; i < items.length; i++) { + this.changedLines.add(startLine + i); + this.markDirty(startLine + i); + } + + // console.log("Parsing took: ", new Date().valueOf() - t1.valueOf()); + } + + /** + * Inserts a string at the given position in the document. + * + * If recordingUndo is set, an UndoStep is inserted into the undoManager, which will record the original + * cursor position. + * + * @param offset the offset to insert at + * @param text the text to insert + * @param oldCursor the [row,col] of the cursor at the start of the operation + */ + insertString( + offset: number, + text: string, + oldSelection?: [number, number], + newSelection?: [number, number] + ): number { + this.changeRange(offset, offset, text, oldSelection, newSelection); + return text.length; + } + + /** + * Deletes count characters starting at offset from the document. + * If recordingUndo is set, adds an undoStep, using oldCursor and newCursor. + * + * @param offset the offset to delete from + * @param count the number of characters to delete + * @param oldCursor the cursor at the start of the operation + * @param newCursor the cursor at the end of the operation + */ + deleteRange( + offset: number, + count: number, + oldSelection?: [number, number], + newSelection?: [number, number] + ) { + this.changeRange(offset, offset + count, '', oldSelection, newSelection); + } + + /** Return the offset of the last character in this model. */ + get maxOffset() { + let max = 0; + for (let i = 0; i < this.lines.length; i++) { + max += this.lines[i].text.length + this.lineEndingLength; + } + return max - 1; + } + + public getTokenCursor(offset: number, previous: boolean = false) { + const [row, col] = this.getRowCol(offset); + const line = this.lines[row]; + let lastIndex = 0; + if (line) { + for (let i = 0; i < line.tokens.length; i++) { + const tk = line.tokens[i]; + if (previous ? tk.offset > col : tk.offset > col) { + return new LispTokenCursor(this, row, previous ? Math.max(0, lastIndex - 1) : lastIndex); + } + lastIndex = i; + } + return new LispTokenCursor(this, row, line.tokens.length - 1); + } else { + throw new Error('Unable to get token cursor for LineInputModel!'); + } + } +} + +export class StringDocument implements EditableDocument { + constructor(contents?: string) { + if (contents) { + this.insertString(contents); + } + } + + selection: ModelEditSelection; + + model: LineInputModel = new LineInputModel(1, this); + + selectionStack: ModelEditSelection[] = []; + + getTokenCursor(offset?: number, previous?: boolean): LispTokenCursor { + if (isUndefined(offset)) { + throw new Error('Expected a cursor for StringDocument!'); + } + + return this.model.getTokenCursor(offset); + } + + insertString(text: string) { + this.model.insertString(0, text); + } + + getSelectionText: () => string; + + delete() { + const p = this.selection.anchor; + return this.model.edit([new ModelEdit('deleteRange', [p, 1])], { + selection: new ModelEditSelection(p), + }); + } + + backspace() { + const p = this.selection.anchor; + return this.model.edit([new ModelEdit('deleteRange', [p - 1, 1])], { + selection: new ModelEditSelection(p - 1), + }); + } +} diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts new file mode 100644 index 0000000..e471d6d --- /dev/null +++ b/src/cursor-doc/paredit.ts @@ -0,0 +1,1561 @@ +import { validPair } from './cdf-edits/hy-lexer'; +import { getIndent } from './indent'; +import { ModelEdit, EditableDocument, ModelEditSelection } from './model'; +import { LispTokenCursor } from './token-cursor'; + +// NB: doc.model.edit returns a Thenable, so that the vscode Editor can compose commands. +// But don't put such chains in this module because that won't work in the repl-console. +// In the repl-console, compose commands just by performing them in succession, making sure +// you provide selections, old and new. + +// TODO: Implement all movement and selection commands here, instead of composing them +// exactly the same way in the editor and in the repl-window. +// Example: paredit.moveToRangeRight(this.readline, paredit.forwardSexpRange(this.readline)) +// => paredit.moveForwardSexp(this.readline) + +export async function killRange( + doc: EditableDocument, + range: [number, number], + start = doc.selection.anchor, + end = doc.selection.active +) { + const [left, right] = [Math.min(...range), Math.max(...range)]; + return doc.model.edit([new ModelEdit('deleteRange', [left, right - left, [start, end]])], { + selection: new ModelEditSelection(left), + }); +} + +export function moveToRangeLeft(doc: EditableDocument, range: [number, number]) { + doc.selection = new ModelEditSelection(Math.min(range[0], range[1])); +} + +export function moveToRangeRight(doc: EditableDocument, range: [number, number]) { + doc.selection = new ModelEditSelection(Math.max(range[0], range[1])); +} + +export function selectRange(doc: EditableDocument, range: [number, number]) { + growSelectionStack(doc, range); +} + +export function selectRangeForward(doc: EditableDocument, range: [number, number]) { + const selectionLeft = doc.selection.anchor; + const rangeRight = Math.max(range[0], range[1]); + growSelectionStack(doc, [selectionLeft, rangeRight]); +} + +export function selectRangeBackward(doc: EditableDocument, range: [number, number]) { + const selectionRight = doc.selection.anchor; + const rangeLeft = Math.min(range[0], range[1]); + growSelectionStack(doc, [selectionRight, rangeLeft]); +} + +export function selectForwardSexp(doc: EditableDocument) { + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? forwardSexpRange + : (doc: EditableDocument) => forwardSexpRange(doc, doc.selection.active, true); + selectRangeForward(doc, rangeFn(doc)); +} + +export function selectRight(doc: EditableDocument) { + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? forwardHybridSexpRange + : (doc: EditableDocument) => forwardHybridSexpRange(doc, doc.selection.active, true); + selectRangeForward(doc, rangeFn(doc)); +} + +export function selectForwardSexpOrUp(doc: EditableDocument) { + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? forwardSexpOrUpRange + : (doc: EditableDocument) => forwardSexpOrUpRange(doc, doc.selection.active, true); + + selectRangeForward(doc, rangeFn(doc)); +} + +export function selectBackwardSexp(doc: EditableDocument) { + const rangeFn = + doc.selection.active <= doc.selection.anchor + ? backwardSexpRange + : (doc: EditableDocument) => backwardSexpRange(doc, doc.selection.active, false); + selectRangeBackward(doc, rangeFn(doc)); +} + +export function selectForwardDownSexp(doc: EditableDocument) { + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? (doc: EditableDocument) => rangeToForwardDownList(doc, doc.selection.active, true) + : (doc: EditableDocument) => rangeToForwardDownList(doc, doc.selection.active, true); + selectRangeForward(doc, rangeFn(doc)); +} + +export function selectBackwardDownSexp(doc: EditableDocument) { + selectRangeBackward(doc, rangeToBackwardDownList(doc)); +} + +export function selectForwardUpSexp(doc: EditableDocument) { + selectRangeForward(doc, rangeToForwardUpList(doc, doc.selection.active)); +} + +export function selectBackwardUpSexp(doc: EditableDocument) { + const rangeFn = + doc.selection.active <= doc.selection.anchor + ? (doc: EditableDocument) => rangeToBackwardUpList(doc, doc.selection.active, false) + : (doc: EditableDocument) => rangeToBackwardUpList(doc, doc.selection.active, false); + selectRangeBackward(doc, rangeFn(doc)); +} + +export function selectBackwardSexpOrUp(doc: EditableDocument) { + const rangeFn = + doc.selection.active <= doc.selection.anchor + ? (doc: EditableDocument) => backwardSexpOrUpRange(doc, doc.selection.active, false) + : (doc: EditableDocument) => backwardSexpOrUpRange(doc, doc.selection.active, false); + selectRangeBackward(doc, rangeFn(doc)); +} + +export function selectCloseList(doc: EditableDocument) { + selectRangeForward(doc, rangeToForwardList(doc, doc.selection.active)); +} + +export function selectOpenList(doc: EditableDocument) { + selectRangeBackward(doc, rangeToBackwardList(doc)); +} + +/** + * Gets the range for the ”current” top level form + * @see ListTokenCursor.rangeForDefun + */ +export function rangeForDefun( + doc: EditableDocument, + offset: number = doc.selection.active, + commentCreatesTopLevel = true +): [number, number] { + const cursor = doc.getTokenCursor(offset); + return cursor.rangeForDefun(offset, commentCreatesTopLevel); +} + +/** + * Required : If the cursor can move up and out of an sexp, it must + * Never : If the cursor is at the inner limit of an sexp, it may not escape + * WhenAtLimit : If the cursor is at the inner limit of an sexp, it may move up and out + */ +enum GoUpSexpOption { + Required, + Never, + WhenAtLimit, +} + +/** + * Return a modified selection range on doc. Moves the right limit around sexps, potentially moving up. + */ +function _forwardSexpRange( + doc: EditableDocument, + offset = Math.max(doc.selection.anchor, doc.selection.active), + goUpSexp: GoUpSexpOption, + goPastWhitespace = false +): [number, number] { + const cursor = doc.getTokenCursor(offset); + + if (goUpSexp == GoUpSexpOption.Never || goUpSexp == GoUpSexpOption.WhenAtLimit) { + // Normalize our position by scooting to the beginning of the closest sexp + cursor.forwardWhitespace(); + + if (cursor.forwardSexp(true, true)) { + if (goPastWhitespace) { + cursor.forwardWhitespace(); + } + return [offset, cursor.offsetStart]; + } + } + + if (goUpSexp == GoUpSexpOption.Required || goUpSexp == GoUpSexpOption.WhenAtLimit) { + cursor.forwardList(); + if (cursor.upList()) { + if (goPastWhitespace) { + cursor.forwardWhitespace(); + } + return [offset, cursor.offsetStart]; + } + } + return [offset, offset]; +} + +/** + * Return a modified selection range on doc. Moves the left limit around sexps, potentially moving up. + */ +function _backwardSexpRange( + doc: EditableDocument, + offset: number = Math.min(doc.selection.anchor, doc.selection.active), + goUpSexp: GoUpSexpOption, + goPastWhitespace = false +): [number, number] { + const cursor = doc.getTokenCursor(offset); + + if (goUpSexp == GoUpSexpOption.Never || goUpSexp == GoUpSexpOption.WhenAtLimit) { + if (!cursor.isWhiteSpace() && cursor.offsetStart < offset) { + // This is because cursor.backwardSexp() can't move backwards when "on" the first sexp inside a list + // TODO: Try to fix this in LispTokenCursor instead. + cursor.forwardSexp(); + } + cursor.backwardWhitespace(); + + if (cursor.backwardSexp(true, true)) { + if (goPastWhitespace) { + cursor.backwardWhitespace(); + } + return [cursor.offsetStart, offset]; + } + } + + if (goUpSexp == GoUpSexpOption.Required || goUpSexp == GoUpSexpOption.WhenAtLimit) { + cursor.backwardList(); + if (cursor.backwardUpList()) { + cursor.forwardSexp(true, true); + cursor.backwardSexp(true, true); + if (goPastWhitespace) { + cursor.backwardWhitespace(); + } + return [cursor.offsetStart, offset]; + } + } + + return [offset, offset]; +} + +export function forwardSexpRange( + doc: EditableDocument, + offset = Math.max(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + return _forwardSexpRange(doc, offset, GoUpSexpOption.Never, goPastWhitespace); +} + +export function backwardSexpRange( + doc: EditableDocument, + offset: number = Math.min(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + return _backwardSexpRange(doc, offset, GoUpSexpOption.Never, goPastWhitespace); +} + +export function forwardListRange( + doc: EditableDocument, + start: number = doc.selection.active +): [number, number] { + const cursor = doc.getTokenCursor(start); + cursor.forwardList(); + return [start, cursor.offsetStart]; +} + +export function backwardListRange( + doc: EditableDocument, + start: number = doc.selection.active +): [number, number] { + const cursor = doc.getTokenCursor(start); + cursor.backwardList(); + return [cursor.offsetStart, start]; +} + +/** + * Aims to find the end of the current form (list|vector|map|set|string etc) + * When there is a newline before the end of the current form either: + * - Return the end of the nearest form to the right of the cursor location if one exists + * - Returns the newline's offset if no form exists + * + * This function's output range is needed to implement features similar to paredit's + * killRight or smartparens' sp-kill-hybrid-sexp. + * + * @param doc + * @param offset + * @param goPastWhitespace + * @returns [number, number] + */ +export function forwardHybridSexpRange( + doc: EditableDocument, + offset = Math.max(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + let cursor = doc.getTokenCursor(offset); + if (cursor.getToken().type === 'open') { + return forwardSexpRange(doc); + } else if (cursor.getToken().type === 'close') { + return [offset, offset]; + } + + const currentLineText = doc.model.getLineText(cursor.line); + const lineStart = doc.model.getOffsetForLine(cursor.line); + const currentLineNewlineOffset = lineStart + currentLineText.length; + const remainderLineText = doc.model.getText(offset, currentLineNewlineOffset + 1); + + cursor.forwardList(); // move to the end of the current form + const currentFormEndToken = cursor.getToken(); + // when we've advanced the cursor but start is behind us then go to the end + // happens when in a clojure comment i.e: ;; ---- + const cursorOffsetEnd = cursor.offsetStart <= offset ? cursor.offsetEnd : cursor.offsetStart; + const text = doc.model.getText(offset, cursorOffsetEnd); + let hasNewline = text.indexOf('\n') > -1; + let end = cursorOffsetEnd; + + // Want the min of closing token or newline + // After moving forward, the cursor is not yet at the end of the current line, + // and it is not a close token. So we include the newline + // because what forms are here extend beyond the end of the current line + if (currentLineNewlineOffset > cursor.offsetEnd && currentFormEndToken.type != 'close') { + hasNewline = true; + end = currentLineNewlineOffset; + } + + if (remainderLineText === '' || remainderLineText === '\n') { + end = currentLineNewlineOffset + doc.model.lineEndingLength; + } else if (hasNewline) { + // Try to find the first open token to the right of the document's cursor location if any + let nearestOpenTokenOffset = -1; + + // Start at the newline. + // Work backwards to find the smallest open token offset + // greater than the document's cursor location if any + cursor = doc.getTokenCursor(currentLineNewlineOffset); + while (cursor.offsetStart > offset) { + while (cursor.backwardSexp()) { + // move backward until the cursor cannot move backward anymore + } + if (cursor.offsetStart > offset) { + nearestOpenTokenOffset = cursor.offsetStart; + cursor = doc.getTokenCursor(cursor.offsetStart - 1); + } + } + + if (nearestOpenTokenOffset > 0) { + cursor = doc.getTokenCursor(nearestOpenTokenOffset); + cursor.forwardList(); + end = cursor.offsetEnd; // include the closing token + } else { + // no open tokens found so the end is the newline + end = currentLineNewlineOffset; + } + } + return [offset, end]; +} + +export function rangeToForwardUpList( + doc: EditableDocument, + offset: number = Math.max(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + return _forwardSexpRange(doc, offset, GoUpSexpOption.Required, goPastWhitespace); +} + +export function rangeToBackwardUpList( + doc: EditableDocument, + offset: number = Math.min(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + return _backwardSexpRange(doc, offset, GoUpSexpOption.Required, goPastWhitespace); +} + +export function forwardSexpOrUpRange( + doc: EditableDocument, + offset = Math.max(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + return _forwardSexpRange(doc, offset, GoUpSexpOption.WhenAtLimit, goPastWhitespace); +} + +export function backwardSexpOrUpRange( + doc: EditableDocument, + offset: number = Math.min(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + return _backwardSexpRange(doc, offset, GoUpSexpOption.WhenAtLimit, goPastWhitespace); +} + +export function rangeToForwardDownList( + doc: EditableDocument, + offset: number = Math.max(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + const cursor = doc.getTokenCursor(offset); + if (cursor.downListSkippingMeta()) { + if (goPastWhitespace) { + cursor.forwardWhitespace(); + } + return [offset, cursor.offsetStart]; + } else { + return [offset, offset]; + } +} + +export function rangeToBackwardDownList( + doc: EditableDocument, + offset: number = Math.min(doc.selection.anchor, doc.selection.active), + goPastWhitespace = false +): [number, number] { + const cursor = doc.getTokenCursor(offset); + do { + cursor.backwardWhitespace(); + if (cursor.getPrevToken().type === 'close') { + break; + } + } while (cursor.backwardSexp()); + if (cursor.backwardDownList()) { + if (goPastWhitespace) { + cursor.backwardWhitespace(); + } + return [cursor.offsetStart, offset]; + } else { + return [offset, offset]; + } +} + +export function rangeToForwardList( + doc: EditableDocument, + offset: number = Math.max(doc.selection.anchor, doc.selection.active) +): [number, number] { + const cursor = doc.getTokenCursor(offset); + if (cursor.forwardList()) { + return [offset, cursor.offsetStart]; + } else { + return [offset, offset]; + } +} + +export function rangeToBackwardList( + doc: EditableDocument, + offset: number = Math.min(doc.selection.anchor, doc.selection.active) +): [number, number] { + const cursor = doc.getTokenCursor(offset); + if (cursor.backwardList()) { + return [cursor.offsetStart, offset]; + } else { + return [offset, offset]; + } +} + +export async function wrapSexpr( + doc: EditableDocument, + open: string, + close: string, + start: number = doc.selection.anchor, + end: number = doc.selection.active, + options = { skipFormat: false } +) { + const cursor = doc.getTokenCursor(end); + if (cursor.withinString() && open == '"') { + open = close = '\\"'; + } + if (start == end) { + // No selection + const currentFormRange = cursor.rangeForCurrentForm(start); + if (currentFormRange) { + const range = currentFormRange; + return doc.model.edit( + [ + new ModelEdit('insertString', [range[1], close]), + new ModelEdit('insertString', [ + range[0], + open, + [end, end], + [start + open.length, start + open.length], + ]), + ], + { + selection: new ModelEditSelection(start + open.length), + skipFormat: options.skipFormat, + } + ); + } + } else { + // there is a selection + const range = [Math.min(start, end), Math.max(start, end)]; + return doc.model.edit( + [ + new ModelEdit('insertString', [range[1], close]), + new ModelEdit('insertString', [range[0], open]), + ], + { + selection: new ModelEditSelection(start + open.length), + skipFormat: options.skipFormat, + } + ); + } +} + +export async function rewrapSexpr( + doc: EditableDocument, + open: string, + close: string, + start: number = doc.selection.anchor, + end: number = doc.selection.active +): Promise> { + const cursor = doc.getTokenCursor(end); + if (cursor.backwardList()) { + const openStart = cursor.offsetStart - 1, + openEnd = cursor.offsetStart; + if (cursor.forwardList()) { + const closeStart = cursor.offsetStart, + closeEnd = cursor.offsetEnd; + return doc.model.edit( + [ + new ModelEdit('changeRange', [closeStart, closeEnd, close]), + new ModelEdit('changeRange', [openStart, openEnd, open]), + ], + { selection: new ModelEditSelection(end) } + ); + } + } +} + +export async function splitSexp(doc: EditableDocument, start: number = doc.selection.active) { + const cursor = doc.getTokenCursor(start); + if (!cursor.withinString() && !(cursor.isWhiteSpace() || cursor.previousIsWhiteSpace())) { + cursor.forwardWhitespace(); + } + const splitPos = cursor.withinString() ? start : cursor.offsetStart; + if (cursor.backwardList()) { + const open = cursor.getPrevToken().raw; + if (cursor.forwardList()) { + const close = cursor.getToken().raw; + return doc.model.edit( + [new ModelEdit('changeRange', [splitPos, splitPos, `${close}${open}`])], + { + selection: new ModelEditSelection(splitPos + 1), + } + ); + } + } +} + +/** + * If `start` is between two strings or two lists of the same type: join them. Otherwise do nothing. + * @param doc + * @param start + */ +export async function joinSexp( + doc: EditableDocument, + start: number = doc.selection.active +): Promise> { + const cursor = doc.getTokenCursor(start); + cursor.backwardWhitespace(); + const prevToken = cursor.getPrevToken(), + prevEnd = cursor.offsetStart; + if (['close', 'str-end', 'str'].includes(prevToken.type)) { + cursor.forwardWhitespace(); + const nextToken = cursor.getToken(), + nextStart = cursor.offsetStart; + if (validPair(nextToken.raw[0], prevToken.raw[prevToken.raw.length - 1])) { + return doc.model.edit( + [ + new ModelEdit('changeRange', [ + prevEnd - 1, + nextStart + 1, + prevToken.type === 'close' ? ' ' : '', + [start, start], + [prevEnd, prevEnd], + ]), + ], + { selection: new ModelEditSelection(prevEnd), formatDepth: 2 } + ); + } + } +} + +export async function spliceSexp( + doc: EditableDocument, + start: number = doc.selection.active, + undoStopBefore = true +): Promise> { + const cursor = doc.getTokenCursor(start); + // TODO: this should unwrap the string, not the enclosing list. + + cursor.backwardList(); + const open = cursor.getPrevToken(); + const beginning = cursor.offsetStart; + if (open.type == 'open') { + cursor.forwardList(); + const close = cursor.getToken(); + const end = cursor.offsetStart; + if (close.type == 'close' && validPair(open.raw, close.raw)) { + return doc.model.edit( + [ + new ModelEdit('changeRange', [end, end + close.raw.length, '']), + new ModelEdit('changeRange', [beginning - open.raw.length, beginning, '']), + ], + { undoStopBefore, selection: new ModelEditSelection(start - 1) } + ); + } + } +} + +export async function killBackwardList(doc: EditableDocument, [start, end]: [number, number]) { + return doc.model.edit( + [new ModelEdit('changeRange', [start, end, '', [end, end], [start, start]])], + { + selection: new ModelEditSelection(start), + } + ); +} + +export async function killForwardList(doc: EditableDocument, [start, end]: [number, number]) { + const cursor = doc.getTokenCursor(start); + const inComment = + (cursor.getToken().type == 'comment' && start > cursor.offsetStart) || + cursor.getPrevToken().type == 'comment'; + return doc.model.edit( + [ + new ModelEdit('changeRange', [ + start, + end, + inComment ? '\n' : '', + [start, start], + [start, start], + ]), + ], + { selection: new ModelEditSelection(start) } + ); +} + +export async function forwardSlurpSexp( + doc: EditableDocument, + start: number = doc.selection.active, + extraOpts = { formatDepth: 1 } +) { + const cursor = doc.getTokenCursor(start); + cursor.forwardList(); + if (cursor.getToken().type == 'close') { + const currentCloseOffset = cursor.offsetStart; + const close = cursor.getToken().raw; + const wsInsideCursor = cursor.clone(); + wsInsideCursor.backwardWhitespace(false); + const wsStartOffset = wsInsideCursor.offsetStart; + cursor.upList(); + const wsOutSideCursor = cursor.clone(); + if (cursor.forwardSexp(true, true)) { + wsOutSideCursor.forwardWhitespace(false); + const wsEndOffset = wsOutSideCursor.offsetStart; + const newCloseOffset = cursor.offsetStart; + const replacedText = doc.model.getText(wsStartOffset, wsEndOffset); + const changeArgs = + replacedText.indexOf('\n') >= 0 + ? [currentCloseOffset, currentCloseOffset + close.length, ''] + : [wsStartOffset, wsEndOffset, ' ']; + return doc.model.edit( + [ + new ModelEdit('insertString', [newCloseOffset, close]), + new ModelEdit('changeRange', changeArgs), + ], + { + ...{ + undoStopBefore: true, + }, + ...extraOpts, + } + ); + } else { + const formatDepth = extraOpts['formatDepth'] ? extraOpts['formatDepth'] : 1; + return forwardSlurpSexp(doc, cursor.offsetStart, { + formatDepth: formatDepth + 1, + }); + } + } +} + +export async function backwardSlurpSexp( + doc: EditableDocument, + start: number = doc.selection.active, + extraOpts = {} +) { + const cursor = doc.getTokenCursor(start); + cursor.backwardList(); + const tk = cursor.getPrevToken(); + if (tk.type == 'open') { + const offset = cursor.clone().previous().offsetStart; + const open = cursor.getPrevToken().raw; + cursor.previous(); + cursor.backwardSexp(true, true); + cursor.forwardWhitespace(false); + if (offset !== cursor.offsetStart) { + return doc.model.edit( + [ + new ModelEdit('deleteRange', [offset, tk.raw.length]), + new ModelEdit('changeRange', [cursor.offsetStart, cursor.offsetStart, open]), + ], + { + ...{ + undoStopBefore: true, + }, + ...extraOpts, + } + ); + } else { + const formatDepth = extraOpts['formatDepth'] ? extraOpts['formatDepth'] : 1; + return backwardSlurpSexp(doc, cursor.offsetStart, { + formatDepth: formatDepth + 1, + }); + } + } +} + +export async function forwardBarfSexp(doc: EditableDocument, start: number = doc.selection.active) { + const cursor = doc.getTokenCursor(start); + cursor.forwardList(); + if (cursor.getToken().type == 'close') { + const offset = cursor.offsetStart, + close = cursor.getToken().raw; + cursor.backwardSexp(true, true); + cursor.backwardWhitespace(); + return doc.model.edit( + [ + new ModelEdit('deleteRange', [offset, close.length]), + new ModelEdit('insertString', [cursor.offsetStart, close]), + ], + start >= cursor.offsetStart + ? { + selection: new ModelEditSelection(cursor.offsetStart), + formatDepth: 2, + } + : { formatDepth: 2 } + ); + } +} + +export async function backwardBarfSexp( + doc: EditableDocument, + start: number = doc.selection.active +) { + const cursor = doc.getTokenCursor(start); + cursor.backwardList(); + const tk = cursor.getPrevToken(); + if (tk.type == 'open') { + cursor.previous(); + const offset = cursor.offsetStart; + const close = cursor.getToken().raw; + cursor.next(); + cursor.forwardSexp(true, true); + cursor.forwardWhitespace(false); + return doc.model.edit( + [ + new ModelEdit('changeRange', [cursor.offsetStart, cursor.offsetStart, close]), + new ModelEdit('deleteRange', [offset, tk.raw.length]), + ], + start <= cursor.offsetStart + ? { + selection: new ModelEditSelection(cursor.offsetStart), + formatDepth: 2, + } + : { formatDepth: 2 } + ); + } +} + +export function open( + doc: EditableDocument, + open: string, + close: string, + start: number = doc.selection.active +) { + const [cs, ce] = [doc.selection.anchor, doc.selection.active]; + doc.insertString(open + doc.getSelectionText() + close); + if (cs != ce) { + doc.selection = new ModelEditSelection(cs + open.length, ce + open.length); + } else { + doc.selection = new ModelEditSelection(start + open.length); + } +} + +function docIsBalanced(doc: EditableDocument, start: number = doc.selection.active): boolean { + const cursor = doc.getTokenCursor(0); + while (cursor.forwardSexp(true, true, true)) { + // move forward until the cursor cannot move forward anymore + } + cursor.forwardWhitespace(true); + return cursor.atEnd(); +} + +export async function close( + doc: EditableDocument, + close: string, + start: number = doc.selection.active +) { + console.log("cursor-doc/paredit.ts/close triggered") + const cursor = doc.getTokenCursor(start); + const inString = cursor.withinString(); + cursor.forwardWhitespace(false); + if (cursor.getToken().raw === close) { + doc.selection = new ModelEditSelection(cursor.offsetEnd); + } else { + if (!inString && docIsBalanced(doc)) { + // Do nothing when there is balance + } else { + return doc.model.edit([new ModelEdit('insertString', [start, close])], { + selection: new ModelEditSelection(start + close.length), + }); + } + } +} + +function onlyWhitespaceLeftOfCursor(doc: EditableDocument, cursor: LispTokenCursor) { + const token = cursor.getToken(); + if (token.type === 'ws') { + return token.offset === 0; + } else if (doc.selection.anchor > cursor.offsetStart) { + return false; + } + const prevToken = cursor.getPrevToken(); + + return prevToken.type === 'ws' && prevToken.offset === 0; +} + +function backspaceOnWhitespaceEdit(doc: EditableDocument, cursor: LispTokenCursor) { + const origIndent = getIndent(doc.model, cursor.offsetStart); + const onCloseToken = cursor.getToken().type === 'close'; + let start = doc.selection.anchor; + let token = cursor.getToken(); + if (token.type === 'ws') { + start = cursor.offsetEnd; + } + cursor.previous(); + const prevToken = cursor.getToken(); + if (prevToken.type === 'ws' && start === cursor.offsetEnd) { + token = prevToken; + } + + let end = start; + if (token.type === 'ws') { + end = cursor.offsetStart; + cursor.previous(); + if (cursor.getToken().type === 'eol') { + end = cursor.offsetStart; + cursor.previous(); + if (cursor.getToken().type === 'ws') { + end = cursor.offsetStart; + cursor.previous(); + } + } + } + + const destTokenType = cursor.getToken().type; + let indent = destTokenType === 'eol' ? origIndent : 1; + if (destTokenType === 'open' || onCloseToken) { + indent = 0; + } + const changeArgs = [start, end, ' '.repeat(indent)]; + return doc.model.edit([new ModelEdit('changeRange', changeArgs)], { + selection: new ModelEditSelection(end + indent), + skipFormat: true, + }); +} + +export async function backspace( + doc: EditableDocument, + start: number = doc.selection.anchor, + end: number = doc.selection.active +): Promise { + if (start != end) { + return doc.backspace(); + } else { + const cursor = doc.getTokenCursor(start); + const nextToken = cursor.getToken(); + const p = start; + const prevToken = + p > cursor.offsetStart && !['open', 'close'].includes(nextToken.type) + ? nextToken + : cursor.getPrevToken(); + if (prevToken.type == 'prompt') { + return new Promise((resolve) => resolve(true)); + } else if (nextToken.type == 'prompt') { + return new Promise((resolve) => resolve(true)); + } else if (doc.model.getText(p - 2, p, true) == '\\"') { + return doc.model.edit([new ModelEdit('deleteRange', [p - 2, 2])], { + selection: new ModelEditSelection(p - 2), + }); + } else if (prevToken.type === 'open' && nextToken.type === 'close') { + return doc.model.edit( + [new ModelEdit('deleteRange', [p - prevToken.raw.length, prevToken.raw.length + 1])], + { + selection: new ModelEditSelection(p - prevToken.raw.length), + } + ); + } else if (!cursor.withinString() && onlyWhitespaceLeftOfCursor(doc, cursor)) { + return backspaceOnWhitespaceEdit(doc, cursor); + } else { + if (['open', 'close'].includes(prevToken.type) && docIsBalanced(doc)) { + doc.selection = new ModelEditSelection(p - prevToken.raw.length); + return new Promise((resolve) => resolve(true)); + } else { + return doc.backspace(); + } + } + } +} + +export async function deleteForward( + doc: EditableDocument, + start: number = doc.selection.anchor, + end: number = doc.selection.active +) { + if (start != end) { + await doc.delete(); + } else { + const cursor = doc.getTokenCursor(start); + const prevToken = cursor.getPrevToken(); + const nextToken = cursor.getToken(); + const p = start; + if (doc.model.getText(p, p + 2, true) == '\\"') { + return doc.model.edit([new ModelEdit('deleteRange', [p, 2])], { + selection: new ModelEditSelection(p), + }); + } else if (prevToken.type === 'open' && nextToken.type === 'close') { + return doc.model.edit( + [new ModelEdit('deleteRange', [p - prevToken.raw.length, prevToken.raw.length + 1])], + { + selection: new ModelEditSelection(p - prevToken.raw.length), + } + ); + } else { + if (['open', 'close'].includes(nextToken.type) && docIsBalanced(doc)) { + doc.selection = new ModelEditSelection(p + 1); + return new Promise((resolve) => resolve(true)); + } else { + return doc.delete(); + } + } + } +} + +export async function stringQuote( + doc: EditableDocument, + start: number = doc.selection.anchor, + end: number = doc.selection.active +) { + if (start != end) { + doc.insertString('"'); + } else { + const cursor = doc.getTokenCursor(start); + if (cursor.withinString()) { + // inside a string, let's be clever + if (cursor.getToken().type == 'close') { + if (doc.model.getText(0, start).endsWith('\\')) { + return doc.model.edit([new ModelEdit('changeRange', [start, start, '"'])], { + selection: new ModelEditSelection(start + 1), + }); + } else { + return close(doc, '"', start); + } + } else { + if (doc.model.getText(0, start).endsWith('\\')) { + return doc.model.edit([new ModelEdit('changeRange', [start, start, '"'])], { + selection: new ModelEditSelection(start + 1), + }); + } else { + return doc.model.edit([new ModelEdit('changeRange', [start, start, '\\"'])], { + selection: new ModelEditSelection(start + 2), + }); + } + } + } else { + return doc.model.edit([new ModelEdit('changeRange', [start, start, '""'])], { + selection: new ModelEditSelection(start + 1), + }); + } + } +} + +export function growSelection( + doc: EditableDocument, + start: number = doc.selection.anchor, + end: number = doc.selection.active +) { + const startC = doc.getTokenCursor(start), + endC = doc.getTokenCursor(end), + emptySelection = startC.equals(endC); + + if (emptySelection) { + const currentFormRange = startC.rangeForCurrentForm(start); + if (currentFormRange) { + growSelectionStack(doc, currentFormRange); + } + } else { + if (startC.getPrevToken().type == 'open' && endC.getToken().type == 'close') { + startC.backwardList(); + startC.backwardUpList(); + endC.forwardList(); + growSelectionStack(doc, [startC.offsetStart, endC.offsetEnd]); + } else { + if (startC.backwardList()) { + // we are in an sexpr. + endC.forwardList(); + endC.previous(); + } else { + if (startC.backwardDownList()) { + startC.backwardList(); + if (emptySelection) { + endC.set(startC); + endC.forwardList(); + endC.next(); + } + startC.previous(); + } else if (startC.downList()) { + if (emptySelection) { + endC.set(startC); + endC.forwardList(); + endC.next(); + } + startC.previous(); + } + } + growSelectionStack(doc, [startC.offsetStart, endC.offsetEnd]); + } + } +} + +export function growSelectionStack(doc: EditableDocument, range: [number, number]) { + const [start, end] = range; + if (doc.selectionStack.length > 0) { + const prev = doc.selectionStack[doc.selectionStack.length - 1]; + if (!(doc.selection.anchor == prev.anchor && doc.selection.active == prev.active)) { + setSelectionStack(doc); + } else if (prev.anchor === range[0] && prev.active === range[1]) { + return; + } + } else { + doc.selectionStack = [doc.selection]; + } + doc.selection = new ModelEditSelection(start, end); + doc.selectionStack.push(doc.selection); +} + +export function shrinkSelection(doc: EditableDocument) { + if (doc.selectionStack.length) { + const latest = doc.selectionStack.pop(); + if ( + doc.selectionStack.length && + latest.anchor == doc.selection.anchor && + latest.active == doc.selection.active + ) { + doc.selection = doc.selectionStack[doc.selectionStack.length - 1]; + } + } +} + +export function setSelectionStack(doc: EditableDocument, selection = doc.selection) { + doc.selectionStack = [selection]; +} + +export async function raiseSexp( + doc: EditableDocument, + start = doc.selection.anchor, + end = doc.selection.active +) { + const cursor = doc.getTokenCursor(end); + const [formStart, formEnd] = cursor.rangeForCurrentForm(start); + const isCaretTrailing = formEnd - start < start - formStart; + const startCursor = doc.getTokenCursor(formStart); + const endCursor = startCursor.clone(); + if (endCursor.forwardSexp()) { + const raised = doc.model.getText(startCursor.offsetStart, endCursor.offsetStart); + startCursor.backwardList(); + endCursor.forwardList(); + if (startCursor.getPrevToken().type == 'open') { + startCursor.previous(); + if (endCursor.getToken().type == 'close') { + return doc.model.edit( + [new ModelEdit('changeRange', [startCursor.offsetStart, endCursor.offsetEnd, raised])], + { + selection: new ModelEditSelection( + isCaretTrailing ? startCursor.offsetStart + raised.length : startCursor.offsetStart + ), + } + ); + } + } + } +} + +export async function convolute( + doc: EditableDocument, + start = doc.selection.anchor, + end = doc.selection.active +) { + if (start == end) { + const cursorStart = doc.getTokenCursor(end); + const cursorEnd = cursorStart.clone(); + + if (cursorStart.backwardList()) { + if (cursorEnd.forwardList()) { + const head = doc.model.getText(cursorStart.offsetStart, end); + if (cursorStart.getPrevToken().type == 'open') { + cursorStart.previous(); + const headStart = cursorStart.clone(); + + if (headStart.backwardList() && headStart.backwardUpList()) { + const headEnd = cursorStart.clone(); + if (headEnd.forwardList() && cursorEnd.getToken().type == 'close') { + return doc.model.edit( + [ + new ModelEdit('changeRange', [headEnd.offsetEnd, headEnd.offsetEnd, ')']), + new ModelEdit('changeRange', [cursorEnd.offsetStart, cursorEnd.offsetEnd, '']), + new ModelEdit('changeRange', [cursorStart.offsetStart, end, '']), + new ModelEdit('changeRange', [ + headStart.offsetStart, + headStart.offsetStart, + '(' + head, + ]), + ], + {} + ); + } + } + } + } + } + } +} + +export async function transpose( + doc: EditableDocument, + left = doc.selection.anchor, + right = doc.selection.active, + newPosOffset: { fromLeft?: number; fromRight?: number } = {} +) { + const cursor = doc.getTokenCursor(right); + cursor.backwardWhitespace(); + if (cursor.getPrevToken().type == 'open') { + cursor.forwardSexp(); + } + cursor.forwardWhitespace(); + if (cursor.getToken().type == 'close') { + cursor.backwardSexp(); + } + if (cursor.getToken().type != 'close') { + const rightStart = cursor.offsetStart; + if (cursor.forwardSexp()) { + const rightEnd = cursor.offsetStart; + cursor.backwardSexp(); + cursor.backwardWhitespace(); + const leftEnd = cursor.offsetStart; + if (cursor.backwardSexp()) { + const leftStart = cursor.offsetStart, + leftText = doc.model.getText(leftStart, leftEnd), + rightText = doc.model.getText(rightStart, rightEnd); + let newCursorPos = leftStart + rightText.length; + if (newPosOffset.fromLeft != undefined) { + newCursorPos = leftStart + newPosOffset.fromLeft; + } else if (newPosOffset.fromRight != undefined) { + newCursorPos = rightEnd - newPosOffset.fromRight; + } + return doc.model.edit( + [ + new ModelEdit('changeRange', [rightStart, rightEnd, leftText]), + new ModelEdit('changeRange', [ + leftStart, + leftEnd, + rightText, + [left, left], + [newCursorPos, newCursorPos], + ]), + ], + { selection: new ModelEditSelection(newCursorPos) } + ); + } + } + } +} + +export const bindingForms = [ + 'let', + 'for', + 'loop', + 'binding', + 'with-local-vars', + 'doseq', + 'with-redefs', + 'when-let', +]; + +function isInPairsList(cursor: LispTokenCursor, pairForms: string[]): boolean { + const probeCursor = cursor.clone(); + if (probeCursor.backwardList()) { + const opening = probeCursor.getPrevToken().raw; + if (opening.endsWith('{') && !opening.endsWith('#{')) { + return true; + } + if (opening.endsWith('[')) { + probeCursor.backwardUpList(); + probeCursor.backwardList(); + if (probeCursor.getPrevToken().raw.endsWith('{')) { + return false; + } + const fn = probeCursor.getFunctionName(); + if (fn && pairForms.includes(fn)) { + return true; + } + } + return false; + } + return false; +} + +/** + * Returns the range of the current form + * or the current form pair, if usePairs is true + */ +function currentSexpsRange( + doc: EditableDocument, + cursor: LispTokenCursor, + offset: number, + usePairs = false +): [number, number] { + const currentSingleRange = cursor.rangeForCurrentForm(offset); + if (usePairs) { + const ranges = cursor.rangesForSexpsInList(); + if (ranges.length > 1) { + const indexOfCurrentSingle = ranges.findIndex( + (r) => r[0] === currentSingleRange[0] && r[1] === currentSingleRange[1] + ); + if (indexOfCurrentSingle % 2 == 0) { + const pairCursor = doc.getTokenCursor(currentSingleRange[1]); + pairCursor.forwardSexp(); + return [currentSingleRange[0], pairCursor.offsetStart]; + } else { + const pairCursor = doc.getTokenCursor(currentSingleRange[0]); + pairCursor.backwardSexp(); + return [pairCursor.offsetStart, currentSingleRange[1]]; + } + } + } + return currentSingleRange; +} + +export async function dragSexprBackward( + doc: EditableDocument, + pairForms = bindingForms, + left = doc.selection.anchor, + right = doc.selection.active +) { + const cursor = doc.getTokenCursor(right); + const usePairs = isInPairsList(cursor, pairForms); + const currentRange = currentSexpsRange(doc, cursor, right, usePairs); + const newPosOffset = right - currentRange[0]; + const backCursor = doc.getTokenCursor(currentRange[0]); + backCursor.backwardSexp(); + const backRange = currentSexpsRange(doc, backCursor, backCursor.offsetStart, usePairs); + if (backRange[0] !== currentRange[0]) { + // there is a sexp to the left + const leftText = doc.model.getText(backRange[0], backRange[1]); + const currentText = doc.model.getText(currentRange[0], currentRange[1]); + return doc.model.edit( + [ + new ModelEdit('changeRange', [currentRange[0], currentRange[1], leftText]), + new ModelEdit('changeRange', [backRange[0], backRange[1], currentText]), + ], + { selection: new ModelEditSelection(backRange[0] + newPosOffset) } + ); + } +} + +export async function dragSexprForward( + doc: EditableDocument, + pairForms = bindingForms, + left = doc.selection.anchor, + right = doc.selection.active +) { + const cursor = doc.getTokenCursor(right); + const usePairs = isInPairsList(cursor, pairForms); + const currentRange = currentSexpsRange(doc, cursor, right, usePairs); + const newPosOffset = currentRange[1] - right; + const forwardCursor = doc.getTokenCursor(currentRange[1]); + forwardCursor.forwardSexp(); + const forwardRange = currentSexpsRange(doc, forwardCursor, forwardCursor.offsetStart, usePairs); + if (forwardRange[0] !== currentRange[0]) { + // there is a sexp to the right + const rightText = doc.model.getText(forwardRange[0], forwardRange[1]); + const currentText = doc.model.getText(currentRange[0], currentRange[1]); + return doc.model.edit( + [ + new ModelEdit('changeRange', [forwardRange[0], forwardRange[1], currentText]), + new ModelEdit('changeRange', [currentRange[0], currentRange[1], rightText]), + ], + { + selection: new ModelEditSelection( + currentRange[1] + (forwardRange[1] - currentRange[1]) - newPosOffset + ), + } + ); + } +} + +export type WhitespaceInfo = { + hasLeftWs: boolean; + leftWsRange: [number, number]; + leftWs: string; + leftWsHasNewline: boolean; + hasRightWs: boolean; + rightWsRange: [number, number]; + rightWs: string; + rightWsHasNewline: boolean; +}; + +/** + * Collect and return information about the current form regarding its surrounding whitespace + * @param doc + * @param p the position in `doc` from where to determine the current form + */ +export function collectWhitespaceInfo( + doc: EditableDocument, + p = doc.selection.active +): WhitespaceInfo { + const cursor = doc.getTokenCursor(p); + const currentRange = cursor.rangeForCurrentForm(p); + const leftWsRight = currentRange[0]; + const leftWsCursor = doc.getTokenCursor(leftWsRight); + const rightWsLeft = currentRange[1]; + const rightWsCursor = doc.getTokenCursor(rightWsLeft); + leftWsCursor.backwardWhitespace(false); + rightWsCursor.forwardWhitespace(false); + const leftWsLeft = leftWsCursor.offsetStart; + const leftWs = doc.model.getText(leftWsLeft, leftWsRight); + const leftWsHasNewline = leftWs.indexOf('\n') !== -1; + const rightWsRight = rightWsCursor.offsetStart; + const rightWs = doc.model.getText(rightWsLeft, rightWsRight); + const rightWsHasNewline = rightWs.indexOf('\n') !== -1; + return { + hasLeftWs: leftWs !== '', + leftWsRange: [leftWsLeft, leftWsRight], + leftWs, + leftWsHasNewline, + hasRightWs: rightWs !== '', + rightWsRange: [rightWsLeft, rightWsRight], + rightWs, + rightWsHasNewline, + }; +} + +export async function dragSexprBackwardUp(doc: EditableDocument, p = doc.selection.active) { + const wsInfo = collectWhitespaceInfo(doc, p); + const cursor = doc.getTokenCursor(p); + const currentRange = cursor.rangeForCurrentForm(p); + if (cursor.backwardList() && cursor.backwardUpList()) { + const listStart = cursor.offsetStart; + const newPosOffset = p - currentRange[0]; + const newCursorPos = listStart + newPosOffset; + const listIndent = cursor.getToken().offset; + let dragText: string, deleteEdit: ModelEdit; + if (wsInfo.hasLeftWs) { + dragText = + doc.model.getText(...currentRange) + + (wsInfo.leftWsHasNewline ? '\n' + ' '.repeat(listIndent) : ' '); + const lineCommentCursor = doc.getTokenCursor(wsInfo.leftWsRange[0]); + const havePrecedingLineComment = lineCommentCursor.getPrevToken().type === 'comment'; + const wsLeftStart = wsInfo.leftWsRange[0] + (havePrecedingLineComment ? 1 : 0); + deleteEdit = new ModelEdit('deleteRange', [wsLeftStart, currentRange[1] - wsLeftStart]); + } else { + dragText = + doc.model.getText(...currentRange) + + (wsInfo.rightWsHasNewline ? '\n' + ' '.repeat(listIndent) : ' '); + deleteEdit = new ModelEdit('deleteRange', [ + currentRange[0], + wsInfo.rightWsRange[1] - currentRange[0], + ]); + } + return doc.model.edit( + [ + deleteEdit, + new ModelEdit('insertString', [listStart, dragText, [p, p], [newCursorPos, newCursorPos]]), + ], + { + selection: new ModelEditSelection(newCursorPos), + skipFormat: false, + undoStopBefore: true, + } + ); + } +} + +export async function dragSexprForwardDown(doc: EditableDocument, p = doc.selection.active) { + const wsInfo = collectWhitespaceInfo(doc, p); + const currentRange = doc.getTokenCursor(p).rangeForCurrentForm(p); + const newPosOffset = p - currentRange[0]; + const cursor = doc.getTokenCursor(currentRange[0]); + while (cursor.forwardSexp()) { + cursor.forwardWhitespace(); + const token = cursor.getToken(); + if (token.type === 'open') { + const listStart = cursor.offsetStart; + const deleteLength = wsInfo.rightWsRange[1] - currentRange[0]; + const insertStart = listStart + token.raw.length; + const newCursorPos = insertStart - deleteLength + newPosOffset; + const insertText = + doc.model.getText(...currentRange) + (wsInfo.rightWsHasNewline ? '\n' : ' '); + return doc.model.edit( + [ + new ModelEdit('insertString', [ + insertStart, + insertText, + [p, p], + [newCursorPos, newCursorPos], + ]), + new ModelEdit('deleteRange', [currentRange[0], deleteLength]), + ], + { + selection: new ModelEditSelection(newCursorPos), + skipFormat: false, + undoStopBefore: true, + } + ); + } + } +} + +export async function dragSexprForwardUp(doc: EditableDocument, p = doc.selection.active) { + const wsInfo = collectWhitespaceInfo(doc, p); + const cursor = doc.getTokenCursor(p); + const currentRange = cursor.rangeForCurrentForm(p); + if (cursor.forwardList() && cursor.upList()) { + const listEnd = cursor.offsetStart; + const newPosOffset = p - currentRange[0]; + const listWsInfo = collectWhitespaceInfo(doc, listEnd); + const dragText = + (listWsInfo.rightWsHasNewline ? '\n' : ' ') + doc.model.getText(...currentRange); + let deleteStart = wsInfo.leftWsRange[0]; + let deleteLength = currentRange[1] - deleteStart; + if (wsInfo.hasRightWs) { + deleteStart = currentRange[0]; + deleteLength = wsInfo.rightWsRange[1] - deleteStart; + } + const newCursorPos = listEnd + newPosOffset + 1 - deleteLength; + return doc.model.edit( + [ + new ModelEdit('insertString', [listEnd, dragText, [p, p], [newCursorPos, newCursorPos]]), + new ModelEdit('deleteRange', [deleteStart, deleteLength]), + ], + { + selection: new ModelEditSelection(newCursorPos), + skipFormat: false, + undoStopBefore: true, + } + ); + } +} + +export async function dragSexprBackwardDown(doc: EditableDocument, p = doc.selection.active) { + const wsInfo = collectWhitespaceInfo(doc, p); + const currentRange = doc.getTokenCursor(p).rangeForCurrentForm(p); + const newPosOffset = p - currentRange[0]; + const cursor = doc.getTokenCursor(currentRange[1]); + while (cursor.backwardSexp()) { + cursor.backwardWhitespace(); + const token = cursor.getPrevToken(); + if (token.type === 'close') { + cursor.previous(); + const listEnd = cursor.offsetStart; + cursor.backwardWhitespace(); + const siblingWsInfo = collectWhitespaceInfo(doc, cursor.offsetStart); + const deleteLength = currentRange[1] - wsInfo.leftWsRange[0]; + const insertStart = listEnd; + const newCursorPos = insertStart + newPosOffset + 1; + let insertText = doc.model.getText(...currentRange); + insertText = (siblingWsInfo.leftWsHasNewline ? '\n' : ' ') + insertText; + return doc.model.edit( + [ + new ModelEdit('deleteRange', [wsInfo.leftWsRange[0], deleteLength]), + new ModelEdit('insertString', [ + insertStart, + insertText, + [p, p], + [newCursorPos, newCursorPos], + ]), + ], + { + selection: new ModelEditSelection(newCursorPos), + skipFormat: false, + undoStopBefore: true, + } + ); + break; + } + } +} + +function adaptContentsToRichComment(contents: string): string { + return contents + .split(/\n/) + .map((line) => ` ${line}`) + .join('\n') + .trim(); +} + +export async function addRichComment( + doc: EditableDocument, + p = doc.selection.active, + contents?: string +) { + const richComment = `(comment\n ${contents ? adaptContentsToRichComment(contents) : ''}\n )`; + let cursor = doc.getTokenCursor(p); + const topLevelRange = rangeForDefun(doc, p, false); + const isInsideForm = !(p <= topLevelRange[0] || p >= topLevelRange[1]); + const checkIfAtStartCursor = doc.getTokenCursor(p); + checkIfAtStartCursor.backwardWhitespace(true); + const isAtStart = checkIfAtStartCursor.atStart(); + if (isInsideForm || isAtStart) { + cursor = doc.getTokenCursor(topLevelRange[1]); + } + const inLineComment = + cursor.getPrevToken().type === 'comment' || cursor.getToken().type === 'comment'; + if (inLineComment) { + cursor.forwardWhitespace(true); + cursor.backwardWhitespace(false); + } + const insertStart = cursor.offsetStart; + const insideNextTopLevelFormPos = rangeToForwardDownList(doc, insertStart)[1]; + if (!contents && insideNextTopLevelFormPos !== insertStart) { + const checkIfRichCommentExistsCursor = doc.getTokenCursor(insideNextTopLevelFormPos); + checkIfRichCommentExistsCursor.forwardWhitespace(true); + if (checkIfRichCommentExistsCursor.getToken().raw == 'comment') { + checkIfRichCommentExistsCursor.forwardSexp(); + checkIfRichCommentExistsCursor.forwardWhitespace(false); + // insert nothing, just place cursor + const newCursorPos = checkIfRichCommentExistsCursor.offsetStart; + return doc.model.edit( + [ + new ModelEdit('insertString', [ + newCursorPos, + '', + [newCursorPos, newCursorPos], + [newCursorPos, newCursorPos], + ]), + ], + { + selection: new ModelEditSelection(newCursorPos), + skipFormat: true, + undoStopBefore: false, + } + ); + } + } + cursor.backwardWhitespace(false); + const leftWs = doc.model.getText(cursor.offsetStart, insertStart); + cursor.forwardWhitespace(false); + const rightWs = doc.model.getText(insertStart, cursor.offsetStart); + const numPrependNls = leftWs.match('\n\n') ? 0 : leftWs.match('\n') ? 1 : 2; + const numAppendNls = rightWs.match('\n\n') ? 0 : rightWs.match('^\n') ? 1 : 2; + const prepend = '\n'.repeat(numPrependNls); + const append = '\n'.repeat(numAppendNls); + const insertText = `${prepend}${richComment}${append}`; + const newCursorPos = insertStart + 11 + numPrependNls * doc.model.lineEndingLength; + return doc.model.edit( + [ + new ModelEdit('insertString', [ + insertStart, + insertText, + [insertStart, insertStart], + [newCursorPos, newCursorPos], + ]), + ], + { + selection: new ModelEditSelection(newCursorPos), + skipFormat: false, + undoStopBefore: true, + } + ); +} diff --git a/src/cursor-doc/token-cursor.ts b/src/cursor-doc/token-cursor.ts new file mode 100644 index 0000000..521f2c8 --- /dev/null +++ b/src/cursor-doc/token-cursor.ts @@ -0,0 +1,938 @@ +import { LineInputModel } from './model'; +import { Token, validPair } from './cdf-edits/hy-lexer'; + +function tokenIsWhiteSpace(token: Token) { + return token.type === 'eol' || token.type == 'ws'; +} + +/** + * A mutable cursor into the token stream. + */ +export class TokenCursor { + constructor(public doc: LineInputModel, public line: number, public token: number) {} + + /** Create a copy of this cursor. */ + clone() { + return new TokenCursor(this.doc, this.line, this.token); + } + + /** + * Sets this TokenCursor state to the same as another. + * @param cursor the cursor to copy state from. + */ + set(cursor: TokenCursor) { + this.doc = cursor.doc; + this.line = cursor.line; + this.token = cursor.token; + } + + /** Return the position */ + get rowCol() { + return [this.line, this.getToken().offset]; + } + + /** Return the offset at the start of the token */ + get offsetStart() { + return this.doc.getOffsetForLine(this.line) + this.getToken().offset; + } + + /** Return the offset at the end of the token */ + get offsetEnd() { + return Math.min( + this.doc.maxOffset, + this.doc.getOffsetForLine(this.line) + this.getToken().offset + this.getToken().raw.length + ); + } + + /** True if we are at the start of the document */ + atStart() { + return this.token == 0 && this.line == 0; + } + + /** True if we are at the end of the document */ + atEnd() { + return ( + this.line == this.doc.lines.length - 1 && + this.token == this.doc.lines[this.line].tokens.length - 1 + ); + } + + /** Move this cursor backwards one token */ + previous() { + if (this.token > 0) { + this.token--; + } else { + if (this.line == 0) { + return; + } + this.line--; + this.token = this.doc.lines[this.line].tokens.length - 1; + } + return this; + } + + /** Move this cursor forwards one token */ + next() { + if (this.token < this.doc.lines[this.line].tokens.length - 1) { + this.token++; + } else { + if (this.line == this.doc.lines.length - 1) { + return; + } + this.line++; + this.token = 0; + } + return this; + } + + /** + * Return the token immediately preceding this cursor. At the start of the file, a token of type "eol" is returned. + */ + getPrevToken(): Token { + if (this.line == 0 && this.token == 0) { + return { type: 'eol', raw: '\n', offset: 0, state: null }; + } + const cursor = this.clone(); + cursor.previous(); + return cursor.getToken(); + } + + /** + * Returns the token at this cursor position. + */ + getToken() { + return this.doc.lines[this.line].tokens[this.token]; + } + + equals(cursor: TokenCursor) { + return this.line == cursor.line && this.token == cursor.token && this.doc == cursor.doc; + } +} + +/** + * Implementation for cursor.rangesForSexpsInList and + * cursor.rowColRangesForSexpsInList + * Returns the ranges for all forms in the current list. + * Returns undefined if the current cursor is not within a list. + * If you are particular about which list type that should be considered, supply an `openingBracket`. + */ + +function _rangesForSexpsInList( + cursor: LispTokenCursor, + useRowCol = false, + openingBracket?: string +): [number, number][] | [[number, number], [number, number]][] { + if (openingBracket !== undefined) { + if (!cursor.backwardListOfType(openingBracket)) { + return undefined; + } + } else { + if (!cursor.backwardList()) { + return undefined; + } + } + const ranges = []; + // TODO: Figure out how to do this ignore skipping more generally in forward/backward this or that. + let ignoreCounter = 0; + while (true) { + cursor.forwardWhitespace(); + const start = useRowCol ? cursor.rowCol : cursor.offsetStart; + if (cursor.getToken().type === 'ignore') { + ignoreCounter++; + cursor.forwardSexp(); + continue; + } + if (cursor.forwardSexp()) { + if (ignoreCounter === 0) { + const end = useRowCol ? cursor.rowCol : cursor.offsetStart; + ranges.push([start, end]); + } else { + ignoreCounter--; + } + } else { + break; + } + } + return ranges; +} + +export class LispTokenCursor extends TokenCursor { + constructor(public doc: LineInputModel, public line: number, public token: number) { + super(doc, line, token); + } + + /** Create a copy of this cursor. */ + clone() { + return new LispTokenCursor(this.doc, this.line, this.token); + } + + tokenBeginsMetadata(): boolean { + return this.getToken().raw.startsWith('^'); + } + + prevTokenBeginsMetadata(): boolean { + return this.getPrevToken().raw.startsWith('^'); + } + /** + * Moves this token past any whitespace or comment. + */ + forwardWhitespace(includeComments = true) { + while (!this.atEnd()) { + switch (this.getToken().type) { + case 'comment': + case 'prompt': + if (!includeComments) { + return; + } + // eslint-disable-next-line no-fallthrough + case 'eol': + case 'ws': + this.next(); + if (['comment', 'prompt'].includes(this.getToken().type) && !includeComments) { + return; + } + continue; + default: + return; + } + } + } + + /** + * Moves this token back past any whitespace or comment. + */ + backwardWhitespace(includeComments = true) { + while (!this.atStart()) { + switch (this.getPrevToken().type) { + case 'comment': + case 'prompt': + if (!includeComments) { + return; + } + // eslint-disable-next-line no-fallthrough + case 'eol': + case 'ws': + this.previous(); + if (['comment', 'prompt'].includes(this.getPrevToken().type) && !includeComments) { + return; + } + continue; + default: + return; + } + } + } + + // Lisp navigation commands begin here. + + // TODO: When f/b sexp, use the stack knowledge to ”flag” unbalance + + /** + * Moves this token forward one s-expression at this level. + * If the next non whitespace token is an open paren, skips past it's matching + * close paren. + * + * If the next token is a form of closing paren, does not move. + * + * @returns true if the cursor was moved, false otherwise. + */ + forwardSexp(skipComments = true, skipMetadata = false, skipIgnoredForms = false): boolean { + // TODO: Consider using a proper bracket stack + const stack = []; + let isMetadata = false; + this.forwardWhitespace(skipComments); + if (this.getToken().type === 'close') { + return false; + } + isMetadata = this.tokenBeginsMetadata(); + while (!this.atEnd()) { + this.forwardWhitespace(skipComments); + const token = this.getToken(); + switch (token.type) { + case 'comment': + this.next(); + this.next(); + break; + case 'prompt': + this.next(); + this.next(); + break; + case 'ignore': + if (skipIgnoredForms) { + this.next(); + this.forwardSexp(skipComments, skipMetadata, skipIgnoredForms); + break; + } + // eslint-disable-next-line no-fallthrough + case 'id': + case 'lit': + case 'kw': + case 'junk': + case 'str-inside': + if (skipMetadata && this.getToken().raw.startsWith('^')) { + this.next(); + } else { + this.next(); + if (stack.length <= 0) { + return true; + } + } + break; + case 'close': { + const close = token.raw; + let open: string; + while ((open = stack.pop())) { + if (validPair(open, close)) { + this.next(); + break; + } + } + if (skipMetadata && isMetadata) { + this.forwardSexp(skipComments, skipMetadata); + } + if (stack.length <= 0) { + return true; + } + break; + } + case 'open': + stack.push(token.raw); + isMetadata = this.tokenBeginsMetadata(); + this.next(); + break; + default: + this.next(); + break; + } + } + } + + /** + * Moves this token backward one s-expression at this level. + * If the previous non whitespace token is a close paren, skips past it's matching + * open paren. + * + * If the previous token is a form of open paren, does not move. + * + * @returns true if the cursor was moved, false otherwise. + */ + backwardSexp( + skipComments = true, + skipMetadata = false, + skipIgnoredForms = false, + skipReaders = true + ) { + const stack = []; + this.backwardWhitespace(skipComments); + if (this.getPrevToken().type === 'open') { + return false; + } + while (!this.atStart()) { + this.backwardWhitespace(skipComments); + const tk = this.getPrevToken(); + switch (tk.type) { + case 'id': + case 'lit': + case 'kw': + case 'ignore': + case 'junk': + case 'comment': + case 'prompt': + case 'str-inside': { + this.previous(); + if (skipReaders) { + this.backwardThroughAnyReader(); + } + if (skipMetadata) { + const metaCursor = this.clone(); + metaCursor.backwardSexp(true, false, false, false); + if (metaCursor.tokenBeginsMetadata()) { + this.backwardSexp(skipComments, skipMetadata, skipIgnoredForms); + } + } + if (skipReaders) { + this.backwardThroughAnyReader(); + } + if (stack.length <= 0) { + return true; + } + break; + } + case 'close': + stack.push(tk.raw); + this.previous(); + break; + case 'open': { + const open = tk.raw; + let close: string; + while ((close = stack.pop())) { + if (validPair(open, close)) { + break; + } + } + this.previous(); + if (skipReaders) { + this.backwardThroughAnyReader(); + } + if (skipMetadata) { + const metaCursor = this.clone(); + metaCursor.backwardSexp(true, false, false, false); + if (metaCursor.tokenBeginsMetadata()) { + this.backwardSexp(skipComments, skipMetadata, skipIgnoredForms); + } + } + if (skipReaders) { + this.backwardThroughAnyReader(); + } + if (stack.length <= 0) { + return true; + } + break; + } + default: + this.previous(); + break; + } + } + } + + /** + * Moves this cursor past the previous non-ws token, if it is a `reader` token. + * Otherwise, this cursor is left unaffected. + */ + backwardThroughAnyReader() { + const cursor = this.clone(); + let hasReader = false; + while (true) { + cursor.backwardWhitespace(); + if (cursor.getPrevToken().type === 'reader') { + cursor.previous(); + this.set(cursor); + hasReader = true; + } else { + break; + } + } + return hasReader; + } + + /** + * Moves this cursor past the next non-ws token, if it is a `reader` token. + * Otherwise, this cursor is left unaffected. + */ + forwardThroughAnyReader() { + const cursor = this.clone(); + let hasReader = false; + while (true) { + cursor.forwardWhitespace(); + if (cursor.getToken().type === 'reader') { + cursor.next(); + this.set(cursor); + hasReader = true; + } else { + break; + } + } + return hasReader; + } + + /** + * Moves this cursor to the close paren of the containing sexpr, or until the end of the document. + */ + forwardList(): boolean { + const cursor = this.clone(); + while (cursor.forwardSexp()) { + // move forward until the cursor cannot move forward anymore + } + if (cursor.getToken().type === 'close') { + const backCursor = cursor.clone(); + if (backCursor.backwardList()) { + this.set(cursor); + return true; + } + } + return false; + } + + /** + * Moves this cursor forwards to the `closingBracket` of the containing sexpr, or until the end of the document. + */ + forwardListOfType(closingBracket: string): boolean { + const cursor = this.clone(); + while (cursor.forwardList()) { + if (cursor.getToken().raw === closingBracket) { + this.set(cursor); + return true; + } + if (!cursor.upList()) { + return false; + } + } + return false; + } + + /** + * Moves this cursor backwards to the open paren of the containing sexpr, or until the start of the document. + */ + backwardList(): boolean { + const cursor = this.clone(); + while (cursor.backwardSexp()) { + // move backward until the cursor cannot move backward anymore + } + if (cursor.getPrevToken().type === 'open') { + const checkCursor = cursor.clone(); + if (checkCursor.backwardUpList() && checkCursor.forwardSexp()) { + this.set(cursor); + return true; + } + } + return false; + } + + /** + * Moves this cursor backwards to the `openingBracket` of the containing sexpr, or until the start of the document. + */ + backwardListOfType(openingBracket: string): boolean { + const cursor = this.clone(); + while (cursor.backwardList()) { + if (cursor.getPrevToken().raw.endsWith(openingBracket)) { + this.set(cursor); + return true; + } + if (!cursor.backwardUpList()) { + return false; + } + } + return false; + } + + /** + * Finds the range of the current list up to `depth`. + * If you are particular about which type of list, supply the `openingBracket`. + * @param openingBracket + */ + rangeForList(depth: number, openingBracket?: string): [number, number] { + const cursor = this.clone(); + let range: [number, number] = undefined; + for (let i = 0; i < depth; i++) { + if (openingBracket === undefined) { + if (!(cursor.backwardList() && cursor.backwardUpList())) { + return range; + } + } else { + if (!(cursor.backwardListOfType(openingBracket) && cursor.backwardUpList())) { + return range; + } + } + const start = cursor.offsetStart; + if (!cursor.forwardSexp()) { + return range; + } + const end = cursor.offsetStart; + range = [start, end]; + } + return range; + } + + /** + * If possible, moves this cursor forwards past any readers and whitespace, + * and then past the immediately following open-paren and returns true. + * If the source does not match this, returns false and does not move the cursor. + */ + downList(): boolean { + const cursor = this.clone(); + cursor.forwardThroughAnyReader(); + cursor.forwardWhitespace(); + if (cursor.getToken().type === 'open') { + cursor.next(); + this.set(cursor); + return true; + } + return false; + } + + /** + * If possible, moves this cursor forwards past any readers, whitespace, and metadata, + * and then past the immediately following open-paren and returns true. + * If the source does not match this, returns false and does not move the cursor. + */ + downListSkippingMeta(): boolean { + const cursor = this.clone(); + do { + cursor.forwardThroughAnyReader(); + cursor.forwardWhitespace(); + if (cursor.getToken().type === 'open' && !cursor.tokenBeginsMetadata()) { + break; + } + } while (cursor.forwardSexp()); + if (cursor.downList()) { + this.set(cursor); + return true; + } + return false; + } + + /** + * If possible, moves this cursor forwards past any whitespace, and then past the immediately following close-paren and returns true. + * If the source does not match this, returns false and does not move the cursor. + */ + upList(): boolean { + const cursor = this.clone(); + cursor.forwardWhitespace(); + if (cursor.getToken().type == 'close') { + cursor.next(); + this.set(cursor); + return true; + } + return false; + } + + /** + * If possible, moves this cursor backwards past any whitespace, and then backwards past the immediately following open-paren and returns true. + * If the source does not match this, returns false and does not move the cursor. + */ + backwardUpList(): boolean { + const cursor = this.clone(); + cursor.backwardThroughAnyReader(); + cursor.backwardWhitespace(); + if (cursor.getPrevToken().type == 'open') { + cursor.previous(); + this.set(cursor); + return true; + } + return false; + } + + /** + * If possible, moves this cursor backwards past any whitespace, and then backwards past the immediately following close-paren and returns true. + * If the source does not match this, returns false and does not move the cursor. + */ + backwardDownList(): boolean { + const cursor = this.clone(); + cursor.backwardWhitespace(); + if (cursor.getPrevToken().type == 'close') { + cursor.previous(); + this.set(cursor); + return true; + } + return false; + } + + /** + * Figures out the `range` for the current form according to this priority: + * 0. If `offset` is within a symbol, literal or keyword + * 1. Else, if `offset` is adjacent after form + * 2. Else, if `offset` is adjacent before a form + * 3. Else, if the previous form is on the same line + * 4. Else, if the next form is on the same line + * 5. Else, the previous form, if any + * 6. Else, the next form, if any + * 7. Else, the current enclosing form, if any + * 8. Else, return `undefined`. + * @param offset the current cursor (caret) offset in the document + */ + rangeForCurrentForm(offset: number): [number, number] { + let afterCurrentFormOffset: number; + // console.log(-1, offset); + + // 0. If `offset` is within or before, a symbol, literal or keyword + if ( + ['id', 'kw', 'lit', 'str-inside'].includes(this.getToken().type) && + !this.tokenBeginsMetadata() + ) { + afterCurrentFormOffset = this.offsetEnd; + } + // console.log(0, afterCurrentFormOffset); + + // 1. Else, if `offset` is adjacent after form + if (afterCurrentFormOffset === undefined) { + const cursor = this.clone(); + cursor.backwardWhitespace(true); + if ( + cursor.offsetStart == offset && + cursor.getToken().type !== 'reader' && + !cursor.tokenBeginsMetadata() && + cursor.getPrevToken().type !== 'reader' && + !cursor.prevTokenBeginsMetadata() + ) { + if (cursor.backwardSexp() && !cursor.tokenBeginsMetadata()) { + afterCurrentFormOffset = offset; + } + } + } + // console.log(1, afterCurrentFormOffset); + + // 2. Else, if `offset` is adjacent before a form + if (afterCurrentFormOffset === undefined) { + const tk = this.getToken(); + const pTk = this.getPrevToken(); + let isAdjacentBefore = + tk.type === 'reader' || + this.tokenBeginsMetadata() || + pTk.type === 'reader' || + this.prevTokenBeginsMetadata() || + tk.type === 'open'; + // console.log(2.1, isAdjacentBefore); + if (!isAdjacentBefore) { + const cursor = this.clone(); + cursor.backwardWhitespace(); + isAdjacentBefore = + cursor.prevTokenBeginsMetadata() || cursor.getPrevToken().type === 'reader'; + } + // console.log(2.2, isAdjacentBefore); + if (!isAdjacentBefore) { + const cursor = this.clone(); + cursor.forwardWhitespace(); + if (cursor.rowCol[0] === this.rowCol[0]) { + isAdjacentBefore = cursor.tokenBeginsMetadata() || cursor.getToken().type === 'reader'; + } + } + // console.log(2.3, isAdjacentBefore); + if (isAdjacentBefore) { + const cursor = this.clone(); + cursor.forwardWhitespace(); + if (cursor.forwardSexp(true, true)) { + afterCurrentFormOffset = cursor.offsetStart; + } + } + } + // console.log(2, afterCurrentFormOffset); + + // 3. Else, if the previous form is on the same line + if (afterCurrentFormOffset === undefined) { + const cursor = this.clone(); + cursor.backwardWhitespace(true); + const afterOffset = cursor.offsetStart; + if (cursor.rowCol[0] === this.rowCol[0]) { + if (cursor.backwardSexp()) { + afterCurrentFormOffset = afterOffset; + } + } + } + // console.log(3, afterCurrentFormOffset); + + // 4. Else, if the next form is on the same line + if (afterCurrentFormOffset === undefined) { + const cursor = this.clone(); + cursor.forwardWhitespace(true); + if (cursor.rowCol[0] === this.rowCol[0]) { + if (cursor.forwardSexp()) { + afterCurrentFormOffset = cursor.offsetStart; + } + } + } + // console.log(4, afterCurrentFormOffset); + + // 5. Else, the previous form, if any + if (afterCurrentFormOffset === undefined) { + const cursor = this.clone(); + cursor.backwardWhitespace(true); + const afterOffset = cursor.offsetStart; + if (cursor.backwardSexp()) { + afterCurrentFormOffset = afterOffset; + } + } + // console.log(5, afterCurrentFormOffset); + + // 6. Else, the next form, if any + if (afterCurrentFormOffset === undefined) { + const cursor = this.clone(); + cursor.forwardWhitespace(); + if (cursor.forwardSexp()) { + afterCurrentFormOffset = cursor.offsetStart; + } + } + // console.log(6, afterCurrentFormOffset); + + // 7. Else, the current enclosing form, if any + if (afterCurrentFormOffset === undefined) { + const cursor = this.clone(); + if (cursor.backwardUpList()) { + if (cursor.forwardSexp()) { + afterCurrentFormOffset = cursor.offsetStart; + } + } + } + // console.log(7, afterCurrentFormOffset); + + // 8. Else, ¯\_(ツ)_/¯ + if (afterCurrentFormOffset === undefined) { + return undefined; // 8. + } + + const currentFormCursor = this.doc.getTokenCursor(afterCurrentFormOffset); + currentFormCursor.backwardSexp(true, true); + return [currentFormCursor.offsetStart, afterCurrentFormOffset]; + } + + rangeForDefun(offset: number, commentCreatesTopLevel = true): [number, number] { + const cursor = this.doc.getTokenCursor(offset); + let lastCandidateRange: [number, number] = cursor.rangeForCurrentForm(offset); + while (cursor.forwardList() && cursor.upList()) { + const commentCursor = cursor.clone(); + commentCursor.backwardDownList(); + if ( + !commentCreatesTopLevel || + commentCursor.getToken().raw !== ')' || + commentCursor.getFunctionName() !== 'comment' + ) { + lastCandidateRange = cursor.rangeForCurrentForm(cursor.offsetStart); + } + } + return lastCandidateRange; + } + + rangesForTopLevelForms(): [number, number][] { + const cursor = new LispTokenCursor(this.doc, 0, 0); + const ranges: [number, number][] = []; + while (cursor.forwardSexp()) { + const end = cursor.offsetStart; + cursor.backwardSexp(); + ranges.push([cursor.offsetStart, end]); + cursor.forwardSexp(); + } + return ranges; + } + + isWhiteSpace(): boolean { + return tokenIsWhiteSpace(this.getToken()); + } + + previousIsWhiteSpace(): boolean { + return tokenIsWhiteSpace(this.getPrevToken()); + } + + withinWhiteSpace(): boolean { + return this.isWhiteSpace() && this.previousIsWhiteSpace(); + } + + /** + * Indicates if the current token is inside a string + */ + withinString() { + const cursor = this.clone(); + cursor.backwardList(); + if (cursor.getPrevToken().type === 'open' && cursor.getPrevToken().raw.endsWith('"') || + cursor.getPrevToken().type === 'open' && cursor.getPrevToken().raw == '```') { + return true; + } + return false; + } + + /** + * Indicates if the current token is in a comment line + */ + withinComment() { + const cursor = this.clone(); + let isComment = + cursor.getToken().type === 'comment' || cursor.getPrevToken().type === 'comment'; + if (!isComment && this.withinWhiteSpace()) { + cursor.forwardWhitespace(false); + isComment = cursor.getToken().type === 'comment'; + if (!isComment) { + cursor.backwardWhitespace(false); + isComment = cursor.getPrevToken().type === 'comment' && cursor.getToken().type !== 'eol'; + } + } + return isComment; + } + + /** + * Tells if the cursor is inside a properly closed list. + */ + withinValidList(): boolean { + const cursor = this.clone(); + while (cursor.forwardSexp()) { + // move forward until the cursor cannot move forward anymore + } + return cursor.getToken().type == 'close'; + } + + /** + * Returns the rowCol ranges for all forms in the current list. + * Returns undefined if the current cursor is not within a list. + * If you are particular about which list type that should be considered, supply an `openingBracket`. + */ + rowColRangesForSexpsInList(openingBracket?: string): [[number, number], [number, number]][] { + const cursor = this.clone(); + return _rangesForSexpsInList(cursor, true, openingBracket) as [ + [number, number], + [number, number] + ][]; + } + + /** + * Returns the rowCol ranges for all forms in the current list. + * Returns undefined if the current cursor is not within a list. + * If you are particular about which list type that should be considered, supply an `openingBracket`. + */ + rangesForSexpsInList(openingBracket?: string): [number, number][] { + const cursor = this.clone(); + return _rangesForSexpsInList(cursor, false, openingBracket) as [number, number][]; + } + + /** + * Tries to move this cursor backwards to the open paren of the function, `level` functions up. + * If there aren't that many functions behind the cursor, the cursor is not moved at all. + * @param levels how many functions up to go before placing the cursor at the start of it. + * @returns `true` if the cursor was moved, otherwise `false` + */ + backwardFunction(levels: number = 0): boolean { + const cursor = this.clone(); + if (!cursor.backwardListOfType('(')) { + return false; + } + for (let i = 0; i < levels; i++) { + if (!cursor.backwardUpList()) { + return false; + } + if (!cursor.backwardListOfType('(')) { + return false; + } + } + this.set(cursor); + return true; + } + + /** + * Get the name of the current function, optionally digging `levels` functions up. + * @param levels how many levels of functions to dig up. + * @returns the function name, or undefined if there is no function there. + */ + getFunctionName(levels: number = 0): string { + const cursor = this.clone(); + if (cursor.backwardFunction(levels)) { + cursor.forwardWhitespace(); + const symbol = cursor.getToken(); + if (symbol.type === 'id') { + return symbol.raw; + } + } + } + + /** + * Get the range of the sexp that is in function position of the current list, optionally digging `levels` functions up. + * @param levels how many levels of functions to dig up. + * @returns the range of the function sexp/form, or undefined if there is no function there. + */ + getFunctionSexpRange(levels: number = 0): [number, number] { + const cursor = this.clone(); + if (cursor.backwardFunction(levels)) { + cursor.forwardWhitespace(); + const start = cursor.offsetStart; + cursor.forwardSexp(true, true, true); + const end = cursor.offsetStart; + return [start, end]; + } + return [undefined, undefined]; + } +} + +/** + * Creates a `LispTokenCursor` for walking and manipulating the string `s`. + */ +export function createStringCursor(s: string): LispTokenCursor { + const model = new LineInputModel(); + model.insertString(0, s); + return model.getTokenCursor(0); +} diff --git a/src/cursor-doc/undo.ts b/src/cursor-doc/undo.ts new file mode 100644 index 0000000..cf5f124 --- /dev/null +++ b/src/cursor-doc/undo.ts @@ -0,0 +1,130 @@ +/** + * A reversable operation to a document of type T. + */ +export abstract class UndoStep { + /** The name of this undo operation. */ + name: string; + /** If true, the UndoManager will not attempt to coalesce events onto this step. */ + undoStop: boolean; + + /** Given the document, undos the effect of this step */ + abstract undo(c: T): void; + + /** Given the document, redoes the effect of this step */ + abstract redo(c: T): void; + + /** + * Given another UndoStep, attempts to modify this undo-step to include the subsequent one. + * If successful, returns true, if unsuccessful, returns false, and the step must be added to the + * UndoManager, too. + */ + coalesce(c: UndoStep): boolean { + return false; + } +} + +export class UndoStepGroup extends UndoStep { + steps: UndoStep[] = []; + + addUndoStep(step: UndoStep) { + const prevStep = this.steps.length && this.steps[this.steps.length - 1]; + + if (prevStep && !prevStep.undoStop && prevStep.coalesce(step)) { + return; + } + this.steps.push(step); + } + + undo(c: T): void { + for (let i = this.steps.length - 1; i >= 0; i--) { + this.steps[i].undo(c); + } + } + + redo(c: T): void { + for (let i = 0; i < this.steps.length; i++) { + this.steps[i].redo(c); + } + } +} + +/** + * Handles the undo/redo stacks. + */ +export class UndoManager { + private undos: UndoStep[] = []; + private redos: UndoStep[] = []; + + private groupedUndo: UndoStepGroup | null; + + /** + * Adds the step to the undo stack, and clears the redo stack. + * If possible, coalesces it into the previous undo. + * + * @param step the UndoStep to add. + */ + addUndoStep(step: UndoStep) { + if (this.groupedUndo) { + this.groupedUndo.addUndoStep(step); + } else if (this.undos.length) { + const prevUndo = this.undos[this.undos.length - 1]; + if (prevUndo.undoStop) { + this.undos.push(step); + } else if (!prevUndo.coalesce(step)) { + this.undos.push(step); + } + } else { + this.undos.push(step); + } + this.redos = []; + } + + withUndo(f: () => void) { + if (!this.groupedUndo) { + try { + this.groupedUndo = new UndoStepGroup(); + f(); + const undo = this.groupedUndo; + this.groupedUndo = null; + switch (undo.steps.length) { + case 0: + break; + case 1: + this.addUndoStep(undo.steps[0]); + break; + default: + this.addUndoStep(undo); + } + } finally { + this.groupedUndo = null; + } + } else { + f(); + } + } + + /** Prevents this undo from becoming coalesced with future undos */ + insertUndoStop() { + if (this.undos.length) { + this.undos[this.undos.length - 1].undoStop = true; + } + } + + /** Performs the top undo operation on the document (if it exists), moving it to the redo stack. */ + undo(c: T) { + if (this.undos.length) { + const step = this.undos.pop(); + step.undo(c); + this.redos.push(step); + } + } + + /** Performs the top redo operation on the document (if it exists), moving it back onto the undo stack. */ + redo(c: T) { + if (this.redos.length) { + const step = this.redos.pop(); + step.redo(c); + this.undos.push(step); + } + } +} diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts new file mode 100644 index 0000000..0e40b21 --- /dev/null +++ b/src/doc-mirror/index.ts @@ -0,0 +1,271 @@ +export { getIndent } from '../cursor-doc/indent'; +import * as vscode from 'vscode'; +import * as utilities from '../utilities'; +import * as formatter from '../calva-fmt/src/format'; +import { LispTokenCursor } from '../cursor-doc/token-cursor'; +import { + ModelEdit, + EditableDocument, + EditableModel, + ModelEditOptions, + LineInputModel, + ModelEditSelection, +} from '../cursor-doc/model'; +import { isUndefined } from 'lodash'; + +const documents = new Map(); + +export class DocumentModel implements EditableModel { + readonly lineEndingLength: number; + lineInputModel: LineInputModel; + + constructor(private document: MirroredDocument) { + this.lineEndingLength = document.document.eol == vscode.EndOfLine.CRLF ? 2 : 1; + this.lineInputModel = new LineInputModel(this.lineEndingLength); + } + + edit(modelEdits: ModelEdit[], options: ModelEditOptions): Thenable { + const editor = utilities.getActiveTextEditor(), + undoStopBefore = !!options.undoStopBefore; + return editor + .edit( + (builder) => { + for (const modelEdit of modelEdits) { + switch (modelEdit.editFn) { + case 'insertString': + this.insertEdit.apply(this, [builder, ...modelEdit.args]); + break; + case 'changeRange': + this.replaceEdit.apply(this, [builder, ...modelEdit.args]); + break; + case 'deleteRange': + this.deleteEdit.apply(this, [builder, ...modelEdit.args]); + break; + default: + break; + } + } + }, + { undoStopBefore, undoStopAfter: false } + ) + .then((isFulfilled) => { + if (isFulfilled) { + if (options.selection) { + this.document.selection = options.selection; + } + if (!options.skipFormat) { + return formatter.formatPosition(editor, false, { + 'format-depth': options.formatDepth ? options.formatDepth : 1, + }); + } + } + return isFulfilled; + }); + } + + private insertEdit( + builder: vscode.TextEditorEdit, + offset: number, + text: string, + oldSelection?: [number, number], + newSelection?: [number, number] + ) { + const editor = utilities.getActiveTextEditor(), + document = editor.document; + builder.insert(document.positionAt(offset), text); + } + + private replaceEdit( + builder: vscode.TextEditorEdit, + start: number, + end: number, + text: string, + oldSelection?: [number, number], + newSelection?: [number, number] + ) { + const editor = utilities.getActiveTextEditor(), + document = editor.document, + range = new vscode.Range(document.positionAt(start), document.positionAt(end)); + builder.replace(range, text); + } + + private deleteEdit( + builder: vscode.TextEditorEdit, + offset: number, + count: number, + oldSelection?: [number, number], + newSelection?: [number, number] + ) { + const editor = utilities.getActiveTextEditor(), + document = editor.document, + range = new vscode.Range(document.positionAt(offset), document.positionAt(offset + count)); + builder.delete(range); + } + + public getText(start: number, end: number, mustBeWithin = false) { + return this.lineInputModel.getText(start, end, mustBeWithin); + } + + public getLineText(line: number) { + return this.lineInputModel.getLineText(line); + } + + getOffsetForLine(line: number) { + return this.lineInputModel.getOffsetForLine(line); + } + + public getTokenCursor(offset: number, previous?: boolean) { + return this.lineInputModel.getTokenCursor(offset, previous); + } +} + +export class MirroredDocument implements EditableDocument { + constructor(public document: vscode.TextDocument) {} + + model = new DocumentModel(this); + + selectionStack: ModelEditSelection[] = []; + + public getTokenCursor( + offset: number = this.selection.active, + previous: boolean = false + ): LispTokenCursor { + return this.model.getTokenCursor(offset, previous); + } + + public insertString(text: string) { + const editor = utilities.getActiveTextEditor(), + selection = editor.selection, + wsEdit = new vscode.WorkspaceEdit(), + // TODO: prob prefer selection.active or .start + edit = vscode.TextEdit.insert(this.document.positionAt(this.selection.anchor), text); + wsEdit.set(this.document.uri, [edit]); + void vscode.workspace.applyEdit(wsEdit).then((_v) => { + editor.selection = selection; + }); + } + + set selection(selection: ModelEditSelection) { + const editor = utilities.getActiveTextEditor(), + document = editor.document, + anchor = document.positionAt(selection.anchor), + active = document.positionAt(selection.active); + editor.selection = new vscode.Selection(anchor, active); + editor.revealRange(new vscode.Range(active, active)); + } + + get selection(): ModelEditSelection { + const editor = utilities.getActiveTextEditor(), + document = editor.document, + anchor = document.offsetAt(editor.selection.anchor), + active = document.offsetAt(editor.selection.active); + return new ModelEditSelection(anchor, active); + } + + public getSelectionText() { + const editor = utilities.getActiveTextEditor(), + selection = editor.selection; + return this.document.getText(selection); + } + + public delete(): Thenable { + return vscode.commands.executeCommand('deleteRight'); + } + + public backspace(): Thenable { + return vscode.commands.executeCommand('deleteLeft'); + } +} + +let registered = false; + +function processChanges(event: vscode.TextDocumentChangeEvent) { + const model = documents.get(event.document).model; + for (const change of event.contentChanges) { + // vscode may have a \r\n marker, so it's line offsets are all wrong. + const myStartOffset = + model.getOffsetForLine(change.range.start.line) + change.range.start.character, + myEndOffset = model.getOffsetForLine(change.range.end.line) + change.range.end.character; + void model.lineInputModel.edit( + [ + new ModelEdit('changeRange', [ + myStartOffset, + myEndOffset, + change.text.replace(/\r\n/g, '\n'), + ]), + ], + {} + ); + } + model.lineInputModel.flushChanges(); + + // we must clear out the repaint cache data, since we don't use it. + model.lineInputModel.dirtyLines = []; + model.lineInputModel.insertedLines.clear(); + model.lineInputModel.deletedLines.clear(); +} + +export function tryToGetDocument(doc: vscode.TextDocument) { + return documents.get(doc); +} + +export function getDocument(doc: vscode.TextDocument) { + const mirrorDoc = tryToGetDocument(doc); + + if (isUndefined(mirrorDoc)) { + throw new Error('Missing mirror document!'); + } + + return mirrorDoc; +} + +export function getDocumentOffset(doc: vscode.TextDocument, position: vscode.Position) { + const model = getDocument(doc).model; + return model.getOffsetForLine(position.line) + position.character; +} + +function addDocument(doc?: vscode.TextDocument): boolean { + if (doc && doc.languageId == 'hy') { + if (!documents.has(doc)) { + const document = new MirroredDocument(doc); + document.model.lineInputModel.insertString(0, doc.getText()); + documents.set(doc, document); + return false; + } else { + return true; + } + } + return false; +} + +export function activate() { + // the last thing we want is to register twice and receive double events... + if (registered) { + return; + } + registered = true; + + addDocument(utilities.tryToGetDocument({})); + + vscode.workspace.onDidCloseTextDocument((e) => { + if (e.languageId == 'hy') { + documents.delete(e); + } + }); + + vscode.window.onDidChangeActiveTextEditor((e) => { + if (e && e.document && e.document.languageId == 'hy') { + addDocument(e.document); + } + }); + + vscode.workspace.onDidOpenTextDocument((doc) => { + addDocument(doc); + }); + + vscode.workspace.onDidChangeTextDocument((e) => { + if (addDocument(e.document)) { + processChanges(e); + } + }); +} diff --git a/src/edit.ts b/src/edit.ts new file mode 100644 index 0000000..93e27c2 --- /dev/null +++ b/src/edit.ts @@ -0,0 +1,39 @@ +import * as vscode from 'vscode'; +import * as util from './utilities'; +import * as docMirror from './doc-mirror/index'; + +// Relies on that `when` claus guards this from being called +// when the cursor is before the comment marker +export function continueCommentCommand() { + const document = util.tryToGetDocument({}); + if (document && document.languageId === 'hy') { + const editor = util.getActiveTextEditor(); + const position = editor.selection.active; + const cursor = docMirror.getDocument(document).getTokenCursor(); + if (cursor.getToken().type !== 'comment') { + if (cursor.getPrevToken().type === 'comment') { + cursor.previous(); + } else { + return; + } + } + const commentOffset = cursor.rowCol[1]; + const commentText = cursor.getToken().raw; + const [_1, startText, bullet, num] = commentText.match(/^([;\s]+)([*-] +|(\d+)\. +)?/) ?? []; + const newNum = num ? parseInt(num) + 1 : undefined; + const bulletText = newNum ? bullet.replace(/\d+/, '' + newNum) : bullet; + const pad = ' '.repeat(commentOffset); + const newText = `${pad}${startText}${bullet ? bulletText : ''}`; + void editor + .edit((edits) => edits.insert(position, `\n${newText}`), { + undoStopAfter: false, + undoStopBefore: true, + }) + .then((fulfilled) => { + if (fulfilled) { + const newPosition = position.with(position.line + 1, newText.length); + editor.selection = new vscode.Selection(newPosition, newPosition); + } + }); + } +} diff --git a/src/evaluate.ts b/src/evaluate.ts new file mode 100644 index 0000000..9604214 --- /dev/null +++ b/src/evaluate.ts @@ -0,0 +1,607 @@ +// import * as vscode from 'vscode'; +// import * as state from './state'; +// import annotations from './providers/annotations'; +// import * as path from 'path'; +// import * as util from './utilities'; +// import { NReplSession, NReplEvaluation } from './nrepl'; +// import statusbar from './statusbar'; +// import { PrettyPrintingOptions } from './printer'; +// import * as outputWindow from './results-output/results-doc'; +// import { DEBUG_ANALYTICS } from './debugger/calva-debug'; +// import * as namespace from './namespace'; +// import * as replHistory from './results-output/repl-history'; +// import { formatAsLineComments } from './results-output/util'; +// import { getStateValue } from '../out/cljs-lib/cljs-lib'; +// import { getConfig } from './config'; +// import * as replSession from './nrepl/repl-session'; +// import * as getText from './util/get-text'; + +// function interruptAllEvaluations() { +// if (util.getConnectedState()) { +// const msgs: string[] = []; +// const nums = NReplEvaluation.interruptAll((msg) => { +// msgs.push(msg); +// }); +// if (msgs.length) { +// outputWindow.append(normalizeNewLinesAndJoin(msgs)); +// } +// NReplSession.getInstances().forEach((session, _index) => { +// session.interruptAll(); +// }); +// if (nums > 0) { +// void vscode.window.showInformationMessage(`Interrupted ${nums} running evaluation(s).`); +// } else { +// void vscode.window.showInformationMessage('Interruption command finished (unknown results)'); +// } +// outputWindow.discardPendingPrints(); +// return; +// } +// void vscode.window.showInformationMessage('Not connected to a REPL server'); +// } + +// async function addAsComment( +// c: number, +// result: string, +// codeSelection: vscode.Selection, +// editor: vscode.TextEditor, +// selection: vscode.Selection +// ) { +// const indent = `${' '.repeat(c)}`, +// output = result +// .replace(/\n\r?$/, '') +// .split(/\n\r?/) +// .join(`\n${indent};; `), +// edit = vscode.TextEdit.insert(codeSelection.end, `\n${indent};; => ${output}\n`), +// wsEdit = new vscode.WorkspaceEdit(); +// wsEdit.set(editor.document.uri, [edit]); +// await vscode.workspace.applyEdit(wsEdit); +// editor.selection = selection; +// } + +// async function evaluateCodeUpdatingUI( +// code: string, +// options, +// selection?: vscode.Selection +// ): Promise { +// const pprintOptions = options.pprintOptions || getConfig().prettyPrintingOptions; +// // passed options overwrite config options +// const evaluationSendCodeToOutputWindow = +// (options.evaluationSendCodeToOutputWindow === undefined || +// options.evaluationSendCodeToOutputWindow === true) && +// getConfig().evaluationSendCodeToOutputWindow; +// const addToHistory = +// (options.addToHistory === undefined || options.addToHistory === true) && +// (evaluationSendCodeToOutputWindow || +// state.extensionContext.workspaceState.get('outputWindowActive')); +// const showErrorMessage = +// options.showErrorMessage === undefined || options.showErrorMessage === true; +// const showResult = options.showResult === undefined || options.showResult === true; +// const line = options.line; +// const column = options.column; +// const filePath = options.filePath; +// const session: NReplSession = options.session; +// const ns = options.ns; +// const editor = util.getActiveTextEditor(); +// let result = null; + +// if (code.length > 0) { +// if (addToHistory) { +// replHistory.addToReplHistory(session.replType, code); +// replHistory.resetState(); +// } + +// const err: string[] = []; + +// if (outputWindow.getNs() !== ns) { +// await session.switchNS(ns); +// } + +// const context: NReplEvaluation = session.eval(code, ns, { +// file: filePath, +// line: line + 1, +// column: column + 1, +// stdout: (m) => { +// outputWindow.append(normalizeNewLines(m)); +// }, +// stderr: (m) => err.push(m), +// pprintOptions: pprintOptions, +// }); + +// try { +// if (evaluationSendCodeToOutputWindow) { +// outputWindow.append(code); +// } + +// let value = await context.value; +// value = util.stripAnsi(context.pprintOut || value); + +// result = value; + +// if (showResult) { +// outputWindow.append(value, async (resultLocation) => { +// if (selection) { +// const c = selection.start.character; +// if (options.replace) { +// const indent = `${' '.repeat(c)}`, +// edit = vscode.TextEdit.replace(selection, value.replace(/\n/gm, '\n' + indent)), +// wsEdit = new vscode.WorkspaceEdit(); +// wsEdit.set(editor.document.uri, [edit]); +// void vscode.workspace.applyEdit(wsEdit); +// } else { +// if (options.comment) { +// await addAsComment(c, value, selection, editor, editor.selection); +// } +// if (!outputWindow.isResultsDoc(editor.document)) { +// annotations.decorateSelection( +// value, +// selection, +// editor, +// editor.selection.active, +// resultLocation, +// annotations.AnnotationStatus.SUCCESS +// ); +// if (!options.comment) { +// annotations.decorateResults(value, false, selection, editor); +// } +// } +// } +// } +// }); +// // May need to move this inside of onResultsAppended callback above, depending on desired ordering of appended results +// if (err.length > 0) { +// const errMsg = `; ${normalizeNewLinesAndJoin(err, true)}`; +// if (context.stacktrace) { +// outputWindow.saveStacktrace(context.stacktrace); +// outputWindow.append(errMsg, (_, afterResultLocation) => { +// outputWindow.markLastStacktraceRange(afterResultLocation); +// }); +// } else { +// outputWindow.append(errMsg); +// } +// } +// } +// } catch (e) { +// if (showErrorMessage) { +// const outputWindowError = err.length +// ? `; ${normalizeNewLinesAndJoin(err, true)}` +// : formatAsLineComments(e); +// outputWindow.append(outputWindowError, async (resultLocation, afterResultLocation) => { +// if (selection) { +// const editorError = util.stripAnsi(err.length ? err.join('\n') : e); +// const currentCursorPos = editor.selection.active; +// if (options.comment) { +// await addAsComment( +// selection.start.character, +// editorError, +// selection, +// editor, +// editor.selection +// ); +// } +// if (!outputWindow.isResultsDoc(editor.document)) { +// annotations.decorateSelection( +// editorError, +// selection, +// editor, +// currentCursorPos, +// resultLocation, +// annotations.AnnotationStatus.ERROR +// ); +// if (!options.comment) { +// annotations.decorateResults(editorError, true, selection, editor); +// } +// } +// } +// if (context.stacktrace && context.stacktrace.stacktrace) { +// outputWindow.markLastStacktraceRange(afterResultLocation); +// } +// }); +// if (context.stacktrace && context.stacktrace.stacktrace) { +// outputWindow.saveStacktrace(context.stacktrace.stacktrace); +// } +// } +// } +// outputWindow.setSession(session, context.ns || ns); +// replSession.updateReplSessionType(); +// } + +// return result; +// } + +// async function evaluateSelection(document = {}, options) { +// const selectionFn: (editor: vscode.TextEditor) => [vscode.Selection, string] = +// options.selectionFn; + +// if (getStateValue('connected')) { +// const editor = util.getActiveTextEditor(); +// state.analytics().logEvent('Evaluation', 'selectionFn').send(); +// const selection = selectionFn(editor); +// const codeSelection: vscode.Selection = selection[0]; +// let code = selection[1]; +// [codeSelection, code]; + +// const doc = util.getDocument(document); +// const ns = namespace.getNamespace(doc); +// const line = codeSelection.start.line; +// const column = codeSelection.start.character; +// const filePath = doc.fileName; +// const session = replSession.getSession(util.getFileType(doc)); + +// if (code.length > 0) { +// if (options.debug) { +// code = '#dbg\n' + code; +// } +// annotations.decorateSelection( +// '', +// codeSelection, +// editor, +// undefined, +// undefined, +// annotations.AnnotationStatus.PENDING +// ); +// await evaluateCodeUpdatingUI( +// code, +// { ...options, ns, line, column, filePath, session }, +// codeSelection +// ); +// outputWindow.appendPrompt(); +// } +// } else { +// void vscode.window.showErrorMessage('Not connected to a REPL'); +// } +// } + +// function printWarningForError(e: any) { +// console.warn(`Unhandled error: ${e.message}`); +// } + +// function normalizeNewLines(str: string, asLineComment = false): string { +// const s = str.replace(/\n\r?$/, ''); +// return asLineComment ? s.replace(/\n\r?/, '\n; ') : s; +// } + +// function normalizeNewLinesAndJoin(strings: string[], asLineComment = false): string { +// return strings +// .map((s) => normalizeNewLines(s, asLineComment), asLineComment) +// .join(`\n${asLineComment ? '; ' : ''}`); +// } + +// function _currentSelectionElseCurrentForm(editor: vscode.TextEditor): getText.SelectionAndText { +// if (editor.selection.isEmpty) { +// return getText.currentFormText(editor?.document, editor.selection.active); +// } else { +// return [editor.selection, editor.document.getText(editor.selection)]; +// } +// } + +// function _currentTopLevelFormText(editor: vscode.TextEditor): getText.SelectionAndText { +// return getText.currentTopLevelFormText(editor?.document, editor?.selection.active); +// } + +// function _currentEnclosingFormText(editor: vscode.TextEditor): getText.SelectionAndText { +// return getText.currentEnclosingFormText(editor?.document, editor?.selection.active); +// } + +// function evaluateSelectionReplace(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// replace: true, +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentSelectionElseCurrentForm, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateSelectionAsComment(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// comment: true, +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentSelectionElseCurrentForm, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateTopLevelFormAsComment(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// comment: true, +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentTopLevelFormText, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateTopLevelForm(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentTopLevelFormText, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateOutputWindowForm(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentTopLevelFormText, +// evaluationSendCodeToOutputWindow: false, +// addToHistory: true, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateCurrentForm(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentSelectionElseCurrentForm, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateEnclosingForm(document = {}, options = {}) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: _currentEnclosingFormText, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateUsingTextAndSelectionGetter( +// getter: (doc: vscode.TextDocument, pos: vscode.Position) => getText.SelectionAndText, +// formatter: (s: string) => string, +// document = {}, +// options = {} +// ) { +// evaluateSelection( +// document, +// Object.assign({}, options, { +// pprintOptions: getConfig().prettyPrintingOptions, +// selectionFn: (editor: vscode.TextEditor) => { +// const [selection, code] = getter(editor?.document, editor?.selection.active); +// return [selection, formatter(code)]; +// }, +// }) +// ).catch(printWarningForError); +// } + +// function evaluateToCursor(document = {}, options = {}) { +// evaluateUsingTextAndSelectionGetter( +// getText.currentEnclosingFormToCursor, +// (code) => `${code}`, +// document, +// options +// ); +// } + +// function evaluateTopLevelFormToCursor(document = {}, options = {}) { +// evaluateUsingTextAndSelectionGetter( +// getText.currentTopLevelFormToCursor, +// (code) => `${code}`, +// document, +// options +// ); +// } + +// function evaluateStartOfFileToCursor(document = {}, options = {}) { +// evaluateUsingTextAndSelectionGetter( +// getText.startOFileToCursor, +// (code) => `${code}`, +// document, +// options +// ); +// } + +// async function loadFile( +// document: vscode.TextDocument | Record | undefined, +// pprintOptions: PrettyPrintingOptions +// ) { +// const doc = util.tryToGetDocument(document); +// const fileType = util.getFileType(doc); +// const ns = namespace.getNamespace(doc); +// const session = replSession.getSession(util.getFileType(doc)); + +// if (doc && doc.languageId == 'clojure' && fileType != 'edn' && getStateValue('connected')) { +// state.analytics().logEvent('Evaluation', 'LoadFile').send(); +// const docUri = outputWindow.isResultsDoc(doc) +// ? await namespace.getUriForNamespace(session, ns) +// : doc.uri; +// const fileName = path.basename(docUri.path); +// const fileContents = await util.getFileContents(docUri.path); + +// outputWindow.append('; Evaluating file: ' + fileName); + +// await session.switchNS(ns); + +// const res = session.loadFile(fileContents, { +// fileName, +// filePath: docUri.path, +// stdout: (m) => outputWindow.append(normalizeNewLines(m)), +// stderr: (m) => outputWindow.append('; ' + normalizeNewLines(m, true)), +// pprintOptions: pprintOptions, +// }); +// try { +// const value = await res.value; +// if (value) { +// outputWindow.append(value); +// } else { +// outputWindow.append('; No results from file evaluation.'); +// } +// } catch (e) { +// outputWindow.append( +// `; Evaluation of file ${fileName} failed: ${e}`, +// (_location, nextLocation) => { +// if (res.stacktrace) { +// outputWindow.saveStacktrace(res.stacktrace.stacktrace); +// outputWindow.markLastStacktraceRange(nextLocation); +// } +// } +// ); +// } +// outputWindow.setSession(session, res.ns || ns); +// replSession.updateReplSessionType(); +// } +// } + +// async function evaluateUser(code: string) { +// const fileType = util.getFileType(util.tryToGetDocument({})), +// session = replSession.getSession(fileType); +// if (session) { +// try { +// await session.eval(code, session.client.ns).value; +// } catch (e) { +// const chan = state.outputChannel(); +// chan.appendLine(`Eval failure: ${e}`); +// } +// } else { +// void vscode.window.showInformationMessage('Not connected to a REPL server'); +// } +// } + +// async function requireREPLUtilitiesCommand() { +// if (util.getConnectedState()) { +// const chan = state.outputChannel(), +// ns = namespace.getDocumentNamespace(util.tryToGetDocument({})), +// CLJS_FORM = "(use '[cljs.repl :only [apropos dir doc find-doc print-doc pst source]])", +// CLJ_FORM = '(clojure.core/apply clojure.core/require clojure.main/repl-requires)', +// sessionType = replSession.getReplSessionTypeFromState(), +// form = sessionType == 'cljs' ? CLJS_FORM : CLJ_FORM, +// fileType = util.getFileType(util.tryToGetDocument({})), +// session = replSession.getSession(fileType); + +// if (session) { +// try { +// await namespace.createNamespaceFromDocumentIfNotExists(util.tryToGetDocument({})); +// await session.switchNS(ns); +// await session.eval(form, ns).value; +// chan.appendLine(`REPL utilities are now available in namespace ${ns}.`); +// } catch (e) { +// chan.appendLine(`REPL utilities could not be acquired for namespace ${ns}: ${e}`); +// } +// } +// } else { +// void vscode.window.showInformationMessage('Not connected to a REPL server'); +// } +// } + +// async function copyLastResultCommand() { +// const chan = state.outputChannel(); +// const session = replSession.getSession(util.getFileType(util.tryToGetDocument({}))); + +// const value = await session.eval('*1', session.client.ns).value; +// if (value !== null) { +// void vscode.env.clipboard.writeText(value); +// void vscode.window.showInformationMessage('Results copied to the clipboard.'); +// } else { +// chan.appendLine('Nothing to copy'); +// } +// } + +// async function togglePrettyPrint() { +// const config = vscode.workspace.getConfiguration('calva'), +// pprintConfigKey = 'prettyPrintingOptions', +// pprintOptions = config.get(pprintConfigKey); +// pprintOptions.enabled = !pprintOptions.enabled; +// if (pprintOptions.enabled && !(pprintOptions.printEngine || pprintOptions.printFn)) { +// pprintOptions.printEngine = 'pprint'; +// } +// await config.update(pprintConfigKey, pprintOptions, vscode.ConfigurationTarget.Global); +// statusbar.update(); +// } + +// async function toggleEvaluationSendCodeToOutputWindow() { +// const config = vscode.workspace.getConfiguration('calva'); +// await config.update( +// 'evaluationSendCodeToOutputWindow', +// !config.get('evaluationSendCodeToOutputWindow'), +// vscode.ConfigurationTarget.Global +// ); +// statusbar.update(); +// } + +// function instrumentTopLevelForm() { +// evaluateSelection( +// {}, +// { +// pprintOptions: getConfig().prettyPrintingOptions, +// debug: true, +// selectionFn: _currentTopLevelFormText, +// } +// ).catch(printWarningForError); +// state +// .analytics() +// .logEvent(DEBUG_ANALYTICS.CATEGORY, DEBUG_ANALYTICS.EVENT_ACTIONS.INSTRUMENT_FORM) +// .send(); +// } + +// export async function evaluateInOutputWindow( +// code: string, +// sessionType: string, +// ns: string, +// options +// ) { +// const outputDocument = await outputWindow.openResultsDoc(); +// const evalPos = outputDocument.positionAt(outputDocument.getText().length); +// try { +// const session = replSession.getSession(sessionType); +// replSession.updateReplSessionType(); +// if (outputWindow.getNs() !== ns) { +// await session.switchNS(ns); +// outputWindow.setSession(session, ns); +// if (options.evaluationSendCodeToOutputWindow !== false) { +// outputWindow.appendPrompt(); +// } +// } + +// return await evaluateCodeUpdatingUI(code, { +// ...options, +// filePath: outputDocument.fileName, +// session, +// ns, +// line: evalPos.line, +// column: evalPos.character, +// }); +// } catch (e) { +// outputWindow.append('; Evaluation failed.'); +// } +// } + +export type customREPLCommandSnippet = { + name: string; + key?: string; + snippet: string; + repl?: string; + ns?: string; +}; + +export default { + // interruptAllEvaluations, + // loadFile, + // evaluateCurrentForm, + // evaluateEnclosingForm, + // evaluateTopLevelForm, + // evaluateSelectionReplace, + // evaluateSelectionAsComment, + // evaluateTopLevelFormAsComment, + // evaluateToCursor, + // evaluateTopLevelFormToCursor, + // evaluateStartOfFileToCursor, + // evaluateUser, + // copyLastResultCommand, + // requireREPLUtilitiesCommand, + // togglePrettyPrint, + // toggleEvaluationSendCodeToOutputWindow, + // instrumentTopLevelForm, + // evaluateInOutputWindow, + // evaluateOutputWindowForm, +}; diff --git a/src/files-cache.ts b/src/files-cache.ts new file mode 100644 index 0000000..ba77eb0 --- /dev/null +++ b/src/files-cache.ts @@ -0,0 +1,77 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +// import * as state from './state'; +import * as path from 'path'; + +const filesCache: Map = new Map(); + +function writeToCache(uri: vscode.Uri) { + try { + const content: string = fs.readFileSync(uri.fsPath, 'utf8'); + filesCache.set(uri.fsPath, content); + } catch { + // if the file is not readable anymore then don't keep old content in cache + filesCache.delete(uri.fsPath); + } +} + +// From '../utilities' +function tryToGetActiveTextEditor(): vscode.TextEditor | undefined { + return vscode.window.activeTextEditor; +} + +function util_tryToGetDocument( + document: vscode.TextDocument | Record | undefined +): vscode.TextDocument | undefined { + const activeTextEditor = tryToGetActiveTextEditor(); + if (document && Object.prototype.hasOwnProperty.call(document, 'fileName')) { + return document as vscode.TextDocument; + } else if (activeTextEditor?.document && activeTextEditor.document.languageId !== 'Log') { + return activeTextEditor.document; + } else if (vscode.window.visibleTextEditors.length > 0) { + const editor = vscode.window.visibleTextEditors.find( + (editor) => editor.document && editor.document.languageId !== 'Log' + ); + return editor?.document; + } +} + +function getProjectWsFolder(): vscode.WorkspaceFolder | undefined { + const doc = util_tryToGetDocument({}); + if (doc) { + const folder = vscode.workspace.getWorkspaceFolder(doc.uri); + if (folder) { + return folder; + } + } + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + return vscode.workspace.workspaceFolders[0]; + } + return undefined; +} + +export function resolvePath(filePath?: string) { + const root = getProjectWsFolder(); + if (filePath && path.isAbsolute(filePath)) { + return filePath; + } + return filePath && root && path.resolve(root.uri.fsPath, filePath); +} + +/** + * Tries to get content of cached file + * @param path - absolute or relative to the project + */ +export const content = (path: string | undefined) => { + const resolvedPath = resolvePath(path); + if (resolvedPath) { + if (!filesCache.has(resolvedPath)) { + writeToCache(vscode.Uri.file(resolvedPath)); + const filesWatcher = vscode.workspace.createFileSystemWatcher(resolvedPath); + filesWatcher.onDidChange(writeToCache); + filesWatcher.onDidCreate(writeToCache); + filesWatcher.onDidDelete((uri) => filesCache.delete(uri.fsPath)); + } + return filesCache.get(resolvedPath); + } +}; diff --git a/src/hyMain.ts b/src/hyMain.ts index e23abe9..9b978ae 100644 --- a/src/hyMain.ts +++ b/src/hyMain.ts @@ -3,6 +3,16 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import {configuration} from './schemeConfiguration'; +import * as paredit from './paredit/extension'; +import * as fmt from './calva-fmt/src/extension'; +import * as model from './cursor-doc/model'; +import * as config from './config'; +import * as whenContexts from './when-contexts'; +import * as edit from './edit'; +import annotations from './providers/annotations'; +import * as util from './utilities'; +import * as state from './state'; +import status from './status'; const windows: boolean = os.platform() == 'win32'; @@ -51,6 +61,47 @@ function thenFocusTextEditor() { setTimeout(() => vscode.commands.executeCommand('workbench.action.focusActiveEditorGroup'), 250); } +async function onDidSave(testController: vscode.TestController, document: vscode.TextDocument) { + const { evaluate, test } = config.getConfig(); + + if (document.languageId !== 'hy') { + return; + } + + // if (test && util.getConnectedState()) { + // // void testRunner.runNamespaceTests(testController, document); + // state.analytics().logEvent('Calva', 'OnSaveTest').send(); + // } else if (evaluate) { + // if (!outputWindow.isResultsDoc(document)) { + // await eval.loadFile(document, config.getConfig().prettyPrintingOptions); + // outputWindow.appendPrompt(); + // state.analytics().logEvent('Calva', 'OnSaveLoad').send(); + // } + // } +} + +function onDidOpen(document) { + if (document.languageId !== 'hy') { + return; + } +} + +function onDidChangeEditorOrSelection(editor: vscode.TextEditor) { + // replHistory.setReplHistoryCommandsActiveContext(editor); + whenContexts.setCursorContextIfChanged(editor); +} + +function setKeybindingsEnabledContext() { + const keybindingsEnabled = vscode.workspace + .getConfiguration() + .get(config.KEYBINDINGS_ENABLED_CONFIG_KEY); + void vscode.commands.executeCommand( + 'setContext', + config.KEYBINDINGS_ENABLED_CONTEXT_KEY, + keybindingsEnabled + ); +} + export function activate(context: vscode.ExtensionContext) { console.log('Extension "vscode-hy" is now active!'); @@ -97,8 +148,70 @@ export function activate(context: vscode.ExtensionContext) { } )); - context.subscriptions.push(vscode.languages.setLanguageConfiguration('scheme', configuration)); + // Inspired by Calva + + context.subscriptions.push( + vscode.commands.registerCommand('hy.continueComment', edit.continueCommentCommand) + ); + + //EVENTS + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((document) => { + onDidOpen(document); + }) + ); + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((document) => { + // void onDidSave(controller, document); + }) + ); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + status.update(); + onDidChangeEditorOrSelection(editor); + }) + ); + context.subscriptions.push( + vscode.window.onDidChangeTextEditorSelection((editor) => { + status.update(); + onDidChangeEditorOrSelection(editor.textEditor); + }) + ); + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(annotations.onDidChangeTextDocument) + ); + + + model.initScanner(vscode.workspace.getConfiguration('editor').get('maxTokenizationLineLength')); + + // Initial set of the provided contexts + setKeybindingsEnabledContext(); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e: vscode.ConfigurationChangeEvent) => { + if (e.affectsConfiguration(config.KEYBINDINGS_ENABLED_CONFIG_KEY)) { + setKeybindingsEnabledContext(); + } + }) + ); + + try { + void fmt.activate(context); + } catch (e) { + console.error('Failed activating Formatter: ' + e.message); + } + + try { + paredit.activate(context); + } catch (e) { + console.error('Failed activating Paredit: ' + e.message); + } + } export function deactivate() { + state.analytics().logEvent('LifeCycle', 'Deactivated').send(); + // jackIn.calvaJackout(); + return paredit.deactivate(); + // return lsp.deactivate(); } \ No newline at end of file diff --git a/src/paredit/extension.ts b/src/paredit/extension.ts new file mode 100644 index 0000000..def26dc --- /dev/null +++ b/src/paredit/extension.ts @@ -0,0 +1,464 @@ +'use strict'; +import { StatusBar } from './statusbar'; +import * as vscode from 'vscode'; +import { + commands, + window, + Event, + EventEmitter, + ExtensionContext, + workspace, + ConfigurationChangeEvent, +} from 'vscode'; +import * as paredit from '../cursor-doc/paredit'; +import * as docMirror from '../doc-mirror/index'; +import { EditableDocument } from '../cursor-doc/model'; +// import { assertIsDefined } from '../utilities'; + +const onPareditKeyMapChangedEmitter = new EventEmitter(); + +const languages = new Set(['clojure', 'lisp', 'scheme', 'hy']); +const enabled = true; + +/** + * Copies the text represented by the range from doc to the clipboard. + * @param doc + * @param range + */ +async function copyRangeToClipboard(doc: EditableDocument, [start, end]) { + const text = doc.model.getText(start, end); + await vscode.env.clipboard.writeText(text); +} + +/** + * Answers true when `calva.paredit.killAlsoCutsToClipboard` is enabled. + * @returns boolean + */ +function shouldKillAlsoCutToClipboard() { + return workspace.getConfiguration().get('hy.paredit.killAlsoCutsToClipboard'); +} + +function assertIsDefined( + value: T | undefined | null, + message: string | (() => string) + ): asserts value is T { + if (value === null || value === undefined) { + throw new Error(typeof message === 'string' ? message : message()); + } + } + +type PareditCommand = { + command: string; + handler: (doc: EditableDocument) => void | Promise; +}; +const pareditCommands: PareditCommand[] = [ + // NAVIGATING + { + command: 'hy.paredit.forwardSexp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeRight(doc, paredit.forwardSexpRange(doc)); + }, + }, + { + command: 'hy.paredit.backwardSexp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeLeft(doc, paredit.backwardSexpRange(doc)); + }, + }, + { + command: 'hy.paredit.forwardDownSexp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeRight(doc, paredit.rangeToForwardDownList(doc)); + }, + }, + { + command: 'hy.paredit.backwardDownSexp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeLeft(doc, paredit.rangeToBackwardDownList(doc)); + }, + }, + { + command: 'hy.paredit.forwardUpSexp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeRight(doc, paredit.rangeToForwardUpList(doc)); + }, + }, + { + command: 'hy.paredit.backwardUpSexp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeLeft(doc, paredit.rangeToBackwardUpList(doc)); + }, + }, + { + command: 'hy.paredit.forwardSexpOrUp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeRight(doc, paredit.forwardSexpOrUpRange(doc)); + }, + }, + { + command: 'hy.paredit.backwardSexpOrUp', + handler: (doc: EditableDocument) => { + paredit.moveToRangeLeft(doc, paredit.backwardSexpOrUpRange(doc)); + }, + }, + { + command: 'hy.paredit.closeList', + handler: (doc: EditableDocument) => { + paredit.moveToRangeRight(doc, paredit.rangeToForwardList(doc)); + }, + }, + { + command: 'hy.paredit.openList', + handler: (doc: EditableDocument) => { + paredit.moveToRangeLeft(doc, paredit.rangeToBackwardList(doc)); + }, + }, + + // SELECTING + { + command: 'hy.paredit.rangeForDefun', + handler: (doc: EditableDocument) => { + paredit.selectRange(doc, paredit.rangeForDefun(doc)); + }, + }, + { + command: 'hy.paredit.sexpRangeExpansion', + handler: paredit.growSelection, + }, // TODO: Inside string should first select contents + { + command: 'hy.paredit.sexpRangeContraction', + handler: paredit.shrinkSelection, + }, + + { + command: 'hy.paredit.selectForwardSexp', + handler: paredit.selectForwardSexp, + }, + { + command: 'hy.paredit.selectRight', + handler: paredit.selectRight, + }, + { + command: 'hy.paredit.selectBackwardSexp', + handler: paredit.selectBackwardSexp, + }, + { + command: 'hy.paredit.selectForwardDownSexp', + handler: paredit.selectForwardDownSexp, + }, + { + command: 'hy.paredit.selectBackwardDownSexp', + handler: paredit.selectBackwardDownSexp, + }, + { + command: 'hy.paredit.selectForwardUpSexp', + handler: paredit.selectForwardUpSexp, + }, + { + command: 'hy.paredit.selectForwardSexpOrUp', + handler: paredit.selectForwardSexpOrUp, + }, + { + command: 'hy.paredit.selectBackwardSexpOrUp', + handler: paredit.selectBackwardSexpOrUp, + }, + { + command: 'hy.paredit.selectBackwardUpSexp', + handler: paredit.selectBackwardUpSexp, + }, + { + command: 'hy.paredit.selectCloseList', + handler: paredit.selectCloseList, + }, + { + command: 'hy.paredit.selectOpenList', + handler: paredit.selectOpenList, + }, + + // EDITING + { + command: 'hy.paredit.slurpSexpForward', + handler: paredit.forwardSlurpSexp, + }, + { + command: 'hy.paredit.barfSexpForward', + handler: paredit.forwardBarfSexp, + }, + { + command: 'hy.paredit.slurpSexpBackward', + handler: paredit.backwardSlurpSexp, + }, + { + command: 'hy.paredit.barfSexpBackward', + handler: paredit.backwardBarfSexp, + }, + { + command: 'hy.paredit.splitSexp', + handler: paredit.splitSexp, + }, + { + command: 'hy.paredit.joinSexp', + handler: paredit.joinSexp, + }, + { + command: 'hy.paredit.spliceSexp', + handler: paredit.spliceSexp, + }, + // ['paredit.transpose', ], // TODO: Not yet implemented + { + command: 'hy.paredit.raiseSexp', + handler: paredit.raiseSexp, + }, + { + command: 'hy.paredit.transpose', + handler: paredit.transpose, + }, + { + command: 'hy.paredit.dragSexprBackward', + handler: paredit.dragSexprBackward, + }, + { + command: 'hy.paredit.dragSexprForward', + handler: paredit.dragSexprForward, + }, + { + command: 'hy.paredit.dragSexprBackwardUp', + handler: paredit.dragSexprBackwardUp, + }, + { + command: 'hy.paredit.dragSexprForwardDown', + handler: paredit.dragSexprForwardDown, + }, + { + command: 'hy.paredit.dragSexprForwardUp', + handler: paredit.dragSexprForwardUp, + }, + { + command: 'hy.paredit.dragSexprBackwardDown', + handler: paredit.dragSexprBackwardDown, + }, + { + command: 'hy.paredit.convolute', + handler: paredit.convolute, + }, + { + command: 'hy.paredit.killRight', + handler: async (doc: EditableDocument) => { + const range = paredit.forwardHybridSexpRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + return paredit.killRange(doc, range); + }, + }, + { + command: 'hy.paredit.killSexpForward', + handler: async (doc: EditableDocument) => { + const range = paredit.forwardSexpRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + return paredit.killRange(doc, range); + }, + }, + { + command: 'hy.paredit.killSexpBackward', + handler: async (doc: EditableDocument) => { + const range = paredit.backwardSexpRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + return paredit.killRange(doc, range); + }, + }, + { + command: 'hy.paredit.killListForward', + handler: async (doc: EditableDocument) => { + const range = paredit.forwardListRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + return await paredit.killForwardList(doc, range); + }, + }, // TODO: Implement with killRange + { + command: 'hy.paredit.killListBackward', + handler: async (doc: EditableDocument) => { + const range = paredit.backwardListRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + return await paredit.killBackwardList(doc, range); + }, + }, // TODO: Implement with killRange + { + command: 'hy.paredit.spliceSexpKillForward', + handler: async (doc: EditableDocument) => { + const range = paredit.forwardListRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + await paredit.killForwardList(doc, range).then((isFulfilled) => { + return paredit.spliceSexp(doc, doc.selection.active, false); + }); + }, + }, + { + command: 'hy.paredit.spliceSexpKillBackward', + handler: async (doc: EditableDocument) => { + const range = paredit.backwardListRange(doc); + if (shouldKillAlsoCutToClipboard()) { + await copyRangeToClipboard(doc, range); + } + await paredit.killBackwardList(doc, range).then((isFulfilled) => { + return paredit.spliceSexp(doc, doc.selection.active, false); + }); + }, + }, + { + command: 'hy.paredit.wrapAroundParens', + handler: (doc: EditableDocument) => { + return paredit.wrapSexpr(doc, '(', ')'); + }, + }, + { + command: 'hy.paredit.wrapAroundSquare', + handler: (doc: EditableDocument) => { + return paredit.wrapSexpr(doc, '[', ']'); + }, + }, + { + command: 'hy.paredit.wrapAroundCurly', + handler: (doc: EditableDocument) => { + return paredit.wrapSexpr(doc, '{', '}'); + }, + }, + { + command: 'hy.paredit.wrapAroundQuote', + handler: (doc: EditableDocument) => { + return paredit.wrapSexpr(doc, '"', '"'); + }, + }, + { + command: 'hy.paredit.rewrapParens', + handler: (doc: EditableDocument) => { + return paredit.rewrapSexpr(doc, '(', ')'); + }, + }, + { + command: 'hy.paredit.rewrapSquare', + handler: (doc: EditableDocument) => { + return paredit.rewrapSexpr(doc, '[', ']'); + }, + }, + { + command: 'hy.paredit.rewrapCurly', + handler: (doc: EditableDocument) => { + return paredit.rewrapSexpr(doc, '{', '}'); + }, + }, + { + command: 'hy.paredit.rewrapQuote', + handler: (doc: EditableDocument) => { + return paredit.rewrapSexpr(doc, '"', '"'); + }, + }, + { + command: 'hy.paredit.deleteForward', + handler: async (doc: EditableDocument) => { + await paredit.deleteForward(doc); + }, + }, + { + command: 'hy.paredit.deleteBackward', + handler: async (doc: EditableDocument) => { + await paredit.backspace(doc); + }, + }, + { + command: 'hy.paredit.forceDeleteForward', + handler: () => { + return vscode.commands.executeCommand('deleteRight'); + }, + }, + { + command: 'hy.paredit.forceDeleteBackward', + handler: () => { + return vscode.commands.executeCommand('deleteLeft'); + }, + }, + { + command: 'hy.paredit.addRichComment', + handler: async (doc: EditableDocument) => { + await paredit.addRichComment(doc); + }, + }, +]; + +function wrapPareditCommand(command: PareditCommand) { + return async () => { + try { + const textEditor = window.activeTextEditor; + + assertIsDefined(textEditor, 'Expected window to have an activeTextEditor!'); + + const mDoc: EditableDocument = docMirror.getDocument(textEditor.document); + if (!enabled || !languages.has(textEditor.document.languageId)) { + return; + } + return command.handler(mDoc); + } catch (e) { + console.error(e.message); + } + }; +} + +export function getKeyMapConf(): string { + const keyMap = workspace.getConfiguration().get('hy.paredit.defaultKeyMap'); + return String(keyMap); +} + +function setKeyMapConf() { + const keyMap = workspace.getConfiguration().get('hy.paredit.defaultKeyMap'); + void commands.executeCommand('setContext', 'paredit:keyMap', keyMap); + onPareditKeyMapChangedEmitter.fire(String(keyMap)); +} +setKeyMapConf(); + +export function activate(context: ExtensionContext) { + const statusBar = new StatusBar(getKeyMapConf()); + + context.subscriptions.push( + statusBar, + commands.registerCommand('paredit.togglemode', () => { + let keyMap = workspace.getConfiguration().get('hy.paredit.defaultKeyMap'); + keyMap = String(keyMap).trim().toLowerCase(); + if (keyMap == 'original') { + void workspace + .getConfiguration() + .update('hy.paredit.defaultKeyMap', 'strict', vscode.ConfigurationTarget.Global); + } else if (keyMap == 'strict') { + void workspace + .getConfiguration() + .update('hy.paredit.defaultKeyMap', 'original', vscode.ConfigurationTarget.Global); + } + }), + window.onDidChangeActiveTextEditor( + (e) => e && e.document && languages.has(e.document.languageId) + ), + workspace.onDidChangeConfiguration((e: ConfigurationChangeEvent) => { + if (e.affectsConfiguration('hy.paredit.defaultKeyMap')) { + setKeyMapConf(); + } + }), + ...pareditCommands.map((command) => + commands.registerCommand(command.command, wrapPareditCommand(command)) + ) + ); +} + +export function deactivate() { + // do nothing +} + +export const onPareditKeyMapChanged: Event = onPareditKeyMapChangedEmitter.event; diff --git a/src/paredit/statusbar.ts b/src/paredit/statusbar.ts new file mode 100644 index 0000000..b6a08f2 --- /dev/null +++ b/src/paredit/statusbar.ts @@ -0,0 +1,77 @@ +'use strict'; +import { window, StatusBarAlignment, StatusBarItem } from 'vscode'; +// import statusbar from '../statusbar'; +import * as paredit from './extension'; + +const color = { + active: 'white', + inactive: '#b3b3b3', + }; + +export class StatusBar { + private _visible: boolean; + private _keyMap: string; + + private _toggleBarItem: StatusBarItem; + + constructor(keymap: string) { + this._toggleBarItem = window.createStatusBarItem(StatusBarAlignment.Right); + this._toggleBarItem.text = '(λ)'; + this._toggleBarItem.tooltip = ''; + this._toggleBarItem.command = 'paredit.togglemode'; + this._visible = false; + this.keyMap = keymap; + + paredit.onPareditKeyMapChanged((keymap) => { + this.keyMap = keymap; + }); + } + + get keyMap() { + return this._keyMap; + } + + set keyMap(keymap: string) { + this._keyMap = keymap; + this.updateUIState(); + } + + updateUIState() { + switch (this.keyMap.trim().toLowerCase()) { + case 'original': + this.visible = true; + this._toggleBarItem.text = '(λ)'; + this._toggleBarItem.tooltip = 'Toggle to Strict Mode'; + this._toggleBarItem.color = undefined; + break; + case 'strict': + this.visible = true; + this._toggleBarItem.text = '[λ]'; + this._toggleBarItem.tooltip = 'Toggle to Original Mode'; + this._toggleBarItem.color = undefined; + break; + default: + this.visible = true; + this._toggleBarItem.text = 'λ'; + this._toggleBarItem.tooltip = + 'hy Paredit Keymap is set to none, Toggle to Strict Mode is Disabled'; + this._toggleBarItem.color = color.inactive; + } + } + + get visible(): boolean { + return this._visible; + } + + set visible(value: boolean) { + if (value) { + this._toggleBarItem.show(); + } else { + this._toggleBarItem.hide(); + } + } + + dispose() { + this._toggleBarItem.dispose(); + } +} diff --git a/src/providers/annotations.ts b/src/providers/annotations.ts new file mode 100644 index 0000000..173779e --- /dev/null +++ b/src/providers/annotations.ts @@ -0,0 +1,206 @@ +import * as vscode from 'vscode'; +import * as _ from 'lodash'; +import * as util from '../utilities'; + +enum AnnotationStatus { + PENDING = 0, + SUCCESS, + ERROR, + REPL_WINDOW, +} + +const selectionBackgrounds = [ + 'rgba(197, 197, 197, 0.07)', + 'rgba(63, 255, 63, 0.05)', + 'rgba(255, 63, 63, 0.06)', + 'rgba(63, 63, 255, 0.1)', +]; + +const selectionRulerColors = ['gray', 'green', 'red', 'blue']; + +const evalResultsDecorationType = vscode.window.createTextEditorDecorationType({ + after: { + textDecoration: 'none', + fontWeight: 'normal', + fontStyle: 'normal', + }, + rangeBehavior: vscode.DecorationRangeBehavior.ClosedOpen, +}); + +let resultsLocations: [vscode.Range, vscode.Position, vscode.Location][] = []; + +function getResultsLocation(pos: vscode.Position): vscode.Location | undefined { + for (const [range, _evaluatePosition, location] of resultsLocations) { + if (range.contains(pos)) { + return location; + } + } +} + +function getEvaluationPosition(pos: vscode.Position): vscode.Position | undefined { + for (const [range, evaluatePosition, _location] of resultsLocations) { + if (range.contains(pos)) { + return evaluatePosition; + } + } +} + +function evaluated(contentText, hoverText, hasError) { + return { + renderOptions: { + after: { + contentText: contentText.replace(/ /g, '\u00a0'), + overflow: 'hidden', + }, + light: { + after: { + color: hasError ? 'rgb(255, 127, 127)' : 'black', + }, + }, + dark: { + after: { + color: hasError ? 'rgb(255, 175, 175)' : 'white', + }, + }, + }, + }; +} + +function createEvalSelectionDecorationType(status: AnnotationStatus) { + return vscode.window.createTextEditorDecorationType({ + backgroundColor: selectionBackgrounds[status], + overviewRulerColor: selectionRulerColors[status], + overviewRulerLane: vscode.OverviewRulerLane.Right, + rangeBehavior: vscode.DecorationRangeBehavior.OpenOpen, + }); +} + +const evalSelectionDecorationTypes = [ + createEvalSelectionDecorationType(AnnotationStatus.PENDING), + createEvalSelectionDecorationType(AnnotationStatus.SUCCESS), + createEvalSelectionDecorationType(AnnotationStatus.ERROR), + createEvalSelectionDecorationType(AnnotationStatus.REPL_WINDOW), +]; + +function setResultDecorations(editor: vscode.TextEditor, ranges) { + const key = editor.document.uri + ':resultDecorationRanges'; + util.cljsLib.setStateValue(key, ranges); + editor.setDecorations(evalResultsDecorationType, ranges); +} + +function setSelectionDecorations(editor, ranges, status) { + const key = editor.document.uri + ':selectionDecorationRanges:' + status; + util.cljsLib.setStateValue(key, ranges); + editor.setDecorations(evalSelectionDecorationTypes[status], ranges); +} + +function clearEvaluationDecorations(editor?: vscode.TextEditor) { + editor = editor || util.tryToGetActiveTextEditor(); + if (editor) { + util.cljsLib.removeStateValue(editor.document.uri + ':resultDecorationRanges'); + setResultDecorations(editor, []); + for (const status of [ + AnnotationStatus.PENDING, + AnnotationStatus.SUCCESS, + AnnotationStatus.ERROR, + AnnotationStatus.REPL_WINDOW, + ]) { + util.cljsLib.removeStateValue(editor.document.uri + ':selectionDecorationRanges:' + status); + setSelectionDecorations(editor, [], status); + } + } + resultsLocations = []; +} + +function clearAllEvaluationDecorations() { + vscode.window.visibleTextEditors.forEach((editor) => { + clearEvaluationDecorations(editor); + }); +} + +function decorateResults( + resultString, + hasError, + codeSelection: vscode.Range, + editor: vscode.TextEditor +) { + const uri = editor.document.uri; + const key = uri + ':resultDecorationRanges'; + let decorationRanges = util.cljsLib.getStateValue(key) || []; + const decoration = evaluated(` => ${resultString} `, resultString, hasError); + decorationRanges = _.filter(decorationRanges, (o) => { + return !o.codeRange.intersection(codeSelection); + }); + decoration['codeRange'] = codeSelection; + decoration['range'] = new vscode.Selection(codeSelection.end, codeSelection.end); + decorationRanges.push(decoration); + setResultDecorations(editor, decorationRanges); +} + +function decorateSelection( + resultString: string, + codeSelection: vscode.Selection, + editor: vscode.TextEditor, + evaluatePosition: vscode.Position, + resultsLocation, + status: AnnotationStatus +) { + const uri = editor.document.uri; + const key = uri + ':selectionDecorationRanges:' + status; + const decoration = {}; + let decorationRanges = util.cljsLib.getStateValue(key) || []; + decorationRanges = _.filter(decorationRanges, (o) => { + return !o.range.intersection(codeSelection); + }); + decoration['range'] = codeSelection; + if (status != AnnotationStatus.PENDING && status != AnnotationStatus.REPL_WINDOW) { + const copyCommandUri = `command:hy.copyAnnotationHoverText?${encodeURIComponent( + JSON.stringify([{ text: resultString }]) + )}`, + copyCommandMd = `[Copy](${copyCommandUri} "Copy results to the clipboard")`; + const openWindowCommandUri = `command:hy.showOutputWindow`, + openWindowCommandMd = `[Open Output Window](${openWindowCommandUri} "Open the output window")`; + const hoverMessage = new vscode.MarkdownString( + `${copyCommandMd} | ${openWindowCommandMd}\n` + '```clojure\n' + resultString + '\n```' + ); + hoverMessage.isTrusted = true; + decoration['hoverMessage'] = status == AnnotationStatus.ERROR ? resultString : hoverMessage; + } + // for (let s = 0; s < evalSelectionDecorationTypes.length; s++) { + // setSelectionDecorations(editor, [], s);. + // } + setSelectionDecorations(editor, [], status); + decorationRanges.push(decoration); + setSelectionDecorations(editor, decorationRanges, status); + if (status == AnnotationStatus.SUCCESS || status == AnnotationStatus.ERROR) { + resultsLocations.push([codeSelection, evaluatePosition, resultsLocation]); + } +} + +function onDidChangeTextDocument(event: vscode.TextDocumentChangeEvent) { + if (event.contentChanges.length) { + const activeTextEditor: vscode.TextEditor | undefined = util.tryToGetActiveTextEditor(); + if (activeTextEditor) { + const activeDocument = activeTextEditor.document, + changeDocument = event.document; + if (activeDocument.uri == changeDocument.uri) { + clearEvaluationDecorations(activeTextEditor); + } + } + } +} + +function copyHoverTextCommand(args: { [x: string]: string }) { + void vscode.env.clipboard.writeText(args['text']); +} +export default { + AnnotationStatus, + clearEvaluationDecorations, + clearAllEvaluationDecorations, + copyHoverTextCommand, + decorateResults, + decorateSelection, + onDidChangeTextDocument, + getResultsLocation, + getEvaluationPosition, +}; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..86c3a02 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,174 @@ +import * as vscode from 'vscode'; +import Analytics from './analytics'; +// import * as util from './utilities'; +// import * as path from 'path'; +// import * as os from 'os'; +import { getStateValue, setStateValue } from '../out/cljs-lib/cljs-lib'; +// import * as projectRoot from './project-root'; + +let extensionContext: vscode.ExtensionContext; +// export function setExtensionContext(context: vscode.ExtensionContext) { +// extensionContext = context; +// if (context.workspaceState.get('selectedCljTypeName') == undefined) { +// void context.workspaceState.update('selectedCljTypeName', 'unknown'); +// } +// } + +// Super-quick fix for: https://github.com/BetterThanTomorrow/calva/issues/144 +// TODO: Revisit the whole state management business. +// function _outputChannel(name: string): vscode.OutputChannel { +// const channel = getStateValue(name); +// if (channel.toJS !== undefined) { +// return channel.toJS(); +// } else { +// return channel; +// } +// } + +// function outputChannel(): vscode.OutputChannel { +// return _outputChannel('outputChannel'); +// } + +// function connectionLogChannel(): vscode.OutputChannel { +// return _outputChannel('connectionLogChannel'); +// } + +function analytics(): Analytics { + const analytics = getStateValue('analytics'); + if (analytics.toJS !== undefined) { + return analytics.toJS(); + } else { + return analytics; + } +} + +// const PROJECT_DIR_KEY = 'connect.projectDir'; +// const PROJECT_DIR_URI_KEY = 'connect.projectDirNew'; +const PROJECT_CONFIG_MAP = 'config'; + +// export function getProjectRootLocal(useCache = true): string | undefined { +// if (useCache) { +// return getStateValue(PROJECT_DIR_KEY); +// } +// } + +export function getProjectConfig(useCache = true) { + if (useCache) { + return getStateValue(PROJECT_CONFIG_MAP); + } +} + +// export function setProjectConfig(config) { +// return setStateValue(PROJECT_CONFIG_MAP, config); +// } + +// export function getProjectRootUri(useCache = true): vscode.Uri | undefined { +// if (useCache) { +// return getStateValue(PROJECT_DIR_URI_KEY); +// } +// } + +// const NON_PROJECT_DIR_KEY = 'hy.connect.nonProjectDir'; + +// export async function getNonProjectRootDir( +// context: vscode.ExtensionContext +// ): Promise { +// let root: vscode.Uri | undefined = undefined; +// if (!process.env['NEW_DRAMS']) { +// root = await context.globalState.get>(NON_PROJECT_DIR_KEY); +// } +// if (root) { +// const createNewOption = 'Create new temp directory, download new files'; +// const useExistingOption = 'Use existing temp directory, reuse any existing files'; +// root = await vscode.window +// .showQuickPick([useExistingOption, createNewOption], { +// placeHolder: 'Reuse the existing REPL temp dir and its files?', +// }) +// .then((option) => { +// return option === useExistingOption ? root : undefined; +// }); +// } +// if (typeof root === 'object') { +// root = vscode.Uri.file(root.path); +// } +// return root; +// } + +// export async function setNonProjectRootDir(context: vscode.ExtensionContext, root: vscode.Uri) { +// await context.globalState.update(NON_PROJECT_DIR_KEY, root); +// } + +// export async function setOrCreateNonProjectRoot( +// context: vscode.ExtensionContext, +// preferProjectDir = false +// ): Promise { +// let root: vscode.Uri | undefined = undefined; +// if (preferProjectDir) { +// root = getProjectRootUri(); +// } +// if (!root) { +// root = await getNonProjectRootDir(context); +// } +// if (!root) { +// const subDir = util.randomSlug(); +// root = vscode.Uri.file(path.join(util.calvaTmpDir(), subDir)); +// await setNonProjectRootDir(context, root); +// } +// await setStateValue(PROJECT_DIR_KEY, path.resolve(root.fsPath ? root.fsPath : root.path)); +// await setStateValue(PROJECT_DIR_URI_KEY, root); +// return root; +// } + +// function getProjectWsFolder(): vscode.WorkspaceFolder | undefined { +// const doc = util.tryToGetDocument({}); +// if (doc) { +// const folder = vscode.workspace.getWorkspaceFolder(doc.uri); +// if (folder) { +// return folder; +// } +// } +// if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { +// return vscode.workspace.workspaceFolders[0]; +// } +// return undefined; +// } + +/** + * Figures out the current clojure project root, and stores it in Calva state + */ +// export async function initProjectDir(uri?: vscode.Uri): Promise { +// if (uri) { +// setStateValue(PROJECT_DIR_KEY, path.resolve(uri.fsPath)); +// setStateValue(PROJECT_DIR_URI_KEY, uri); +// } else { +// const candidatePaths = await projectRoot.findProjectRootPaths(); +// const closestRootPath = await projectRoot.findClosestProjectRootPath(candidatePaths); +// const projectRootPath = await projectRoot.pickProjectRootPath(candidatePaths, closestRootPath); +// if (projectRootPath !== undefined) { +// setStateValue(PROJECT_DIR_KEY, projectRootPath); +// setStateValue(PROJECT_DIR_URI_KEY, vscode.Uri.file(projectRootPath)); +// } else { +// await setOrCreateNonProjectRoot(extensionContext, true); +// } +// } +// } + +/** + * + * Tries to resolve absolute path in relation to project root + * @param filePath - absolute or relative to the project + */ +// export function resolvePath(filePath?: string) { +// const root = getProjectWsFolder(); +// if (filePath && path.isAbsolute(filePath)) { +// return filePath; +// } +// return filePath && root && path.resolve(root.uri.fsPath, filePath); +// } + +export { + extensionContext, + // outputChannel, + // connectionLogChannel, + analytics +}; diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..0bc1985 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; +// import statusbar from './statusbar'; +import * as state from './state'; +import { getConfig } from './config'; +// import { updateReplSessionType } from './nrepl/repl-session'; + +// function updateNeedReplUi(isNeeded: boolean, context = state.extensionContext) { +// void context.workspaceState.update('needReplUi', isNeeded); +// update(context); +// } + +// function shouldshowReplUi(context = state.extensionContext): boolean { +// return context.workspaceState.get('needReplUi') || !getConfig().hideReplUi; +// } + +function update(context = state.extensionContext) { + // void vscode.commands.executeCommand('setContext', 'calva:showReplUi', shouldshowReplUi(context)); + // updateReplSessionType(); + // statusbar.update(context); +} + +export default { + update, + // updateNeedReplUi, + // shouldshowReplUi, +}; diff --git a/src/utilities.ts b/src/utilities.ts new file mode 100644 index 0000000..9cb49b7 --- /dev/null +++ b/src/utilities.ts @@ -0,0 +1,586 @@ +import * as vscode from 'vscode'; +// import { https } from 'follow-redirects'; +import * as _ from 'lodash'; +// import * as state from './state'; +// import * as path from 'path'; +// import * as os from 'os'; +// import * as fs from 'fs'; +// import * as JSZip from 'jszip'; +// import * as outputWindow from './results-output/results-doc'; +import * as cljsLib from '../out/cljs-lib/cljs-lib'; +// import * as url from 'url'; +import { isUndefined } from 'lodash'; +// import { isNullOrUndefined } from 'util'; + +// const specialWords = ['-', '+', '/', '*']; //TODO: Add more here +// const syntaxQuoteSymbol = '`'; + +// export function stripAnsi(str: string) { +// return str.replace( +// // eslint-disable-next-line no-control-regex +// /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g, +// '' +// ); +// } + +// export const isDefined = (value: T | undefined | null): value is T => { +// return !isNullOrUndefined(value); +// }; + +// This needs to be a function and not an arrow function +// because assertion types are special. +// export function assertIsDefined( +// value: T | undefined | null, +// message: string | (() => string) +// ): asserts value is T { +// if (isNullOrUndefined(value)) { +// throw new Error(typeof message === 'string' ? message : message()); +// } +// } + +// export function escapeStringRegexp(s: string): string { +// return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +// } + +// export function isNonEmptyString(value: any): boolean { +// return typeof value == 'string' && value.length > 0; +// } + +// async function quickPickSingle(opts: { +// title?: string; +// values: string[]; +// saveAs: string; +// default?: string; +// placeHolder: string; +// autoSelect?: boolean; +// }) { +// if (opts.values.length == 0) { +// return; +// } +// const saveAs = `qps-${opts.saveAs}`; +// const selected = opts.default ?? state.extensionContext.workspaceState.get(saveAs); + +// let result; +// if (opts.autoSelect && opts.values.length == 1) { +// result = opts.values[0]; +// } else { +// result = await quickPick(opts.values, selected ? [selected] : [], [], { +// title: opts.title, +// placeHolder: opts.placeHolder, +// ignoreFocusOut: true, +// }); +// } +// void state.extensionContext.workspaceState.update(saveAs, result); +// return result; +// } + +// async function quickPickMulti(opts: { values: string[]; saveAs: string; placeHolder: string }) { +// const saveAs = `qps-${opts.saveAs}`; +// const selected = state.extensionContext.workspaceState.get(saveAs) || []; +// const result = await quickPick(opts.values, [], selected, { +// placeHolder: opts.placeHolder, +// canPickMany: true, +// ignoreFocusOut: true, +// }); +// void state.extensionContext.workspaceState.update(saveAs, result); +// return result; +// } + +// Testing facility. +// Recreated every time we create a new quickPick +// let quickPickActive: Promise; + +// function quickPick( +// itemsToPick: string[], +// active: string[], +// selected: string[], +// options: vscode.QuickPickOptions & { canPickMany: true } +// ): Promise; +// function quickPick( +// itemsToPick: string[], +// active: string[], +// selected: string[], +// options: vscode.QuickPickOptions +// ): Promise; + +// async function quickPick( +// itemsToPick: string[], +// active: string[], +// selected: string[], +// options: vscode.QuickPickOptions +// ): Promise { +// const items = itemsToPick.map((x) => ({ label: x })); + +// const qp = vscode.window.createQuickPick(); +// quickPickActive = new Promise((resolve) => qp.onDidChangeActive((e) => resolve())); +// qp.canSelectMany = !!options.canPickMany; +// qp.title = options.title; +// qp.placeholder = options.placeHolder; +// qp.ignoreFocusOut = !!options.ignoreFocusOut; +// qp.matchOnDescription = !!options.matchOnDescription; +// qp.matchOnDetail = !!options.matchOnDetail; +// qp.items = items; +// qp.activeItems = items.filter((x) => active.indexOf(x.label) != -1); +// qp.selectedItems = items.filter((x) => selected.indexOf(x.label) != -1); +// return new Promise((resolve, reject) => { +// qp.show(); +// qp.onDidAccept(() => { +// if (qp.canSelectMany) { +// resolve(qp.selectedItems.map((x) => x.label)); +// } else if (qp.selectedItems.length) { +// resolve(qp.selectedItems[0].label); +// } else { +// resolve(undefined); +// } +// qp.hide(); +// quickPickActive = undefined; +// }); +// qp.onDidHide(() => { +// resolve([]); +// qp.hide(); +// quickPickActive = undefined; +// }); +// }); +// } + +// function getCljsReplStartCode() { +// return vscode.workspace.getConfiguration('hy').startCLJSREPLCommand; +// } + +// function getShadowCljsReplStartCode(build) { +// return '(shadow.cljs.devtools.api/nrepl-select ' + build + ')'; +// } + +// function getActualWord(document, position, selected, word) { +// if (selected === undefined) { +// const selectedChar = document +// .lineAt(position.line) +// .text.slice(position.character, position.character + 1), +// isFn = +// document.lineAt(position.line).text.slice(position.character - 1, position.character) === +// '('; +// if (selectedChar !== undefined && specialWords.indexOf(selectedChar) !== -1 && isFn) { +// return selectedChar; +// } else { +// return ''; +// } +// } else { +// return word && word.startsWith(syntaxQuoteSymbol) ? word.substr(1) : word; +// } +// } + +// function getWordAtPosition(document, position) { +// const selected = document.getWordRangeAtPosition(position), +// selectedText = +// selected !== undefined +// ? document.getText(new vscode.Range(selected.start, selected.end)) +// : '', +// text = getActualWord(document, position, selected, selectedText); +// return text; +// } + +function tryToGetDocument( + document: vscode.TextDocument | Record | undefined +): vscode.TextDocument | undefined { + const activeTextEditor = tryToGetActiveTextEditor(); + if (document && Object.prototype.hasOwnProperty.call(document, 'fileName')) { + return document as vscode.TextDocument; + } else if (activeTextEditor?.document && activeTextEditor.document.languageId !== 'Log') { + return activeTextEditor.document; + } else if (vscode.window.visibleTextEditors.length > 0) { + const editor = vscode.window.visibleTextEditors.find( + (editor) => editor.document && editor.document.languageId !== 'Log' + ); + return editor?.document; + } +} + +// function getDocument(document: vscode.TextDocument | Record): vscode.TextDocument { +// const doc = tryToGetDocument(document); + +// if (isUndefined(doc)) { +// throw new Error('Expected an activeTextEditor with a document!'); +// } + +// return doc; +// } + +// function getFileType(document: vscode.TextDocument | Record | undefined) { +// const doc = tryToGetDocument(document); + +// if (doc) { +// return path.extname(doc.fileName).replace(/^\./, ''); +// } else { +// return 'clj'; +// } +// } + +// function getLaunchingState() { +// return cljsLib.getStateValue('launching'); +// } + +// function setLaunchingState(value: any) { +// void vscode.commands.executeCommand('setContext', 'calva:launching', Boolean(value)); +// cljsLib.setStateValue('launching', value); +// } + +function getConnectedState() { + return cljsLib.getStateValue('connected'); +} + +// function setConnectedState(value: boolean) { +// void vscode.commands.executeCommand('setContext', 'calva:connected', value); +// cljsLib.setStateValue('connected', value); +// } + +// function getConnectingState() { +// return cljsLib.getStateValue('connecting'); +// } + +// function setConnectingState(value: boolean) { +// if (value) { +// void vscode.commands.executeCommand('setContext', 'calva:connecting', true); +// cljsLib.setStateValue('connecting', true); +// } else { +// void vscode.commands.executeCommand('setContext', 'calva:connecting', false); +// cljsLib.setStateValue('connecting', false); +// } +// } + +// ERROR HELPERS +// const ERROR_TYPE = { +// WARNING: 'warning', +// ERROR: 'error', +// }; + +// function logSuccess(results) { +// const chan = state.outputChannel(); +// chan.appendLine('Evaluation completed successfully'); +// _.each(results, (r) => { +// const value = Object.prototype.hasOwnProperty.call(r, 'value') ? r.value : null; +// const out = Object.prototype.hasOwnProperty.call(r, 'out') ? r.out : null; +// if (value !== null) { +// chan.appendLine('=>\n' + value); +// } +// if (out !== null) { +// chan.appendLine('out:\n' + out); +// } +// }); +// } + +// function logError(error) { +// outputWindow.append('; ' + error.reason); +// if ( +// error.line !== undefined && +// error.line !== null && +// error.column !== undefined && +// error.column !== null +// ) { +// outputWindow.append('; at line: ' + error.line + ' and column: ' + error.column); +// } +// } + +// function markError(error) { +// if (error.line === null) { +// error.line = 0; +// } +// if (error.column === null) { +// error.column = 0; +// } + +// const diagnostic = cljsLib.getStateValue('diagnosticCollection'), +// editor = getActiveTextEditor(); + +// //editor.selection = new vscode.Selection(position, position); +// const line = error.line - 1, +// column = error.column, +// lineLength = editor.document.lineAt(line).text.length, +// lineText = editor.document.lineAt(line).text.substring(column, lineLength), +// firstWordStart = column + lineText.indexOf(' '), +// existing = diagnostic.get(editor.document.uri), +// err = new vscode.Diagnostic( +// new vscode.Range(line, column, line, firstWordStart), +// error.reason, +// vscode.DiagnosticSeverity.Error +// ); + +// const errors = existing !== undefined && existing.length > 0 ? [...existing, err] : [err]; +// diagnostic.set(editor.document.uri, errors); +// } + +// function logWarning(warning) { +// outputWindow.append('; ' + warning.reason); +// if (warning.line !== null) { +// if (warning.column !== null) { +// outputWindow.append('; at line: ' + warning.line + ' and column: ' + warning.column); +// } else { +// outputWindow.append('; at line: ' + warning.line); +// } +// } +// } + +// function markWarning(warning) { +// if (warning.line === null) { +// warning.line = 0; +// } +// if (warning.column === null) { +// warning.column = 0; +// } + +// const diagnostic = cljsLib.getStateValue('diagnosticCollection'), +// editor = getActiveTextEditor(); + +// //editor.selection = new vscode.Selection(position, position); +// const line = Math.max(0, warning.line - 1), +// column = warning.column, +// lineLength = editor.document.lineAt(line).text.length, +// existing = diagnostic.get(editor.document.uri), +// warn = new vscode.Diagnostic( +// new vscode.Range(line, column, line, lineLength), +// warning.reason, +// vscode.DiagnosticSeverity.Warning +// ); + +// const warnings = existing !== undefined && existing.length > 0 ? [...existing, warn] : [warn]; +// diagnostic.set(editor.document.uri, warnings); +// } + +// async function promptForUserInputString(prompt: string): Promise { +// return vscode.window.showInputBox({ +// prompt: prompt, +// ignoreFocusOut: true, +// }); +// } + +// function filterVisibleRanges( +// editor: vscode.TextEditor, +// ranges: vscode.Range[], +// combine = true +// ): vscode.Range[] { +// let filtered: vscode.Range[] = []; +// editor.visibleRanges.forEach((visibleRange) => { +// const visibles = ranges.filter((r) => { +// return ( +// visibleRange.contains(r.start) || visibleRange.contains(r.end) || r.contains(visibleRange) +// ); +// }); +// filtered = filtered.concat( +// combine ? [new vscode.Range(visibles[0].start, visibles[visibles.length - 1].end)] : visibles +// ); +// }); +// return filtered; +// } + +// function scrollToBottom(editor: vscode.TextEditor) { +// const lastPos = editor.document.positionAt(Infinity); +// editor.selection = new vscode.Selection(lastPos, lastPos); +// editor.revealRange(new vscode.Range(lastPos, lastPos)); +// } + +// async function getFileContents(path: string) { +// const doc = vscode.workspace.textDocuments.find((document) => document.uri.path === path); +// if (doc) { +// return doc.getText(); +// } +// if (path.match(/jar!\//)) { +// return await getJarContents(path); +// } +// return fs.readFileSync(path).toString(); +// } + +// function jarFilePathComponents(uri: vscode.Uri | string) { +// const rawPath = typeof uri === 'string' ? uri : uri.path; +// const replaceRegex = os.platform() === 'win32' ? /file:\/*/ : /file:/; +// return rawPath.replace(replaceRegex, '').split('!/'); +// } + +/** + * Gets the contents of a file in a zip + * @param uri url to jar file, followed by "!/" and than the url inside the jar + * @returns contents of the file or an empty string + */ +// async function getJarContents(uri: vscode.Uri | string) { +// return new Promise((resolve, _reject) => { +// const [pathToJar, pathToFileInJar] = jarFilePathComponents(uri); + +// fs.readFile(pathToJar, (err, data) => { +// const zip = new JSZip(); +// zip +// .loadAsync(data) +// .then((new_zip) => { +// const fileInJar = new_zip.file(pathToFileInJar); + +// if (fileInJar) { +// return fileInJar.async('string').then((value) => { +// resolve(value); +// }); +// } + +// return resolve(''); +// }) +// .catch((_) => { +// return resolve(''); +// }); +// }); +// }); +// } + +// function sortByPresetOrder(arr: any[], presetOrder: any[]) { +// const result: any[] = []; +// presetOrder.forEach((preset) => { +// if (arr.indexOf(preset) != -1) { +// result.push(preset); +// } +// }); +// return [...result, ...arr.filter((e) => !presetOrder.includes(e))]; +// } + +// function writeTextToFile(uri: vscode.Uri, text: string): Thenable { +// const ab = new ArrayBuffer(text.length); +// const ui8a = new Uint8Array(ab); +// for (let i = 0, strLen = text.length; i < strLen; i++) { +// ui8a[i] = text.charCodeAt(i); +// } +// return vscode.workspace.fs.writeFile(uri, ui8a); +// } + +// async function downloadFromUrl(url: string, savePath: string) { +// return new Promise((resolve, reject) => { +// const saveFile = fs.createWriteStream(savePath); +// https.get(url, (res) => { +// if (res.statusCode === 200) { +// res.pipe(saveFile); +// } else { +// saveFile.close(); +// reject(new Error(`Server responded with ${res.statusCode}: ${res.statusMessage}`)); +// } +// res.on('end', () => { +// saveFile.close(); +// resolve(true); +// }); +// res.on('error', (err: any) => { +// console.error(`Error downloading file from ${url}: ${err.message}`); +// reject(err); +// }); +// }); +// }); +// } + +// async function fetchFromUrl(fullUrl: string): Promise { +// const q = url.parse(fullUrl); +// return new Promise((resolve, reject) => { +// https +// .get( +// { +// host: q.hostname, +// path: q.pathname, +// port: q.port, +// headers: { 'user-agent': 'node.js' }, +// }, +// (res) => { +// let data = ''; +// res.on('data', (chunk: any) => { +// data += chunk; +// }); +// res.on('end', () => { +// resolve(data); +// }); +// } +// ) +// .on('error', (err: any) => { +// console.error(`Error downloading file from ${url}: ${err.message}`); +// reject(err); +// }); +// }); +// } + +// function randomSlug(length = 7) { +// return Math.random().toString(36).substring(7); +// } + +// function hyTmpDir() { +// return path.join(os.tmpdir(), 'hy-lang.hy'); +// } + +// const isWindows = process.platform === 'win32'; + +// export async function isDocumentWritable(document: vscode.TextDocument): Promise { +// if (!vscode.workspace.fs.isWritableFileSystem(document.uri.scheme)) { +// return false; +// } +// const fileStat = await vscode.workspace.fs.stat(document.uri); + +// // I'm not sure in which cases fileStat permissions can be missing +// // and so it's not clear what to do if it is. For the moment we can +// // ignore this to maintain current behavior. +// // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion +// return (fileStat.permissions! & vscode.FilePermission.Readonly) !== 1; +// } + +// Returns the elements of coll with duplicates removed +// (See clojure.core/distinct). +function distinct(coll: T[]): T[] { + return [...new Set(coll)]; +} + +function tryToGetActiveTextEditor(): vscode.TextEditor | undefined { + return vscode.window.activeTextEditor; +} + +function getActiveTextEditor(): vscode.TextEditor { + const editor = tryToGetActiveTextEditor(); + + if (isUndefined(editor)) { + throw new Error('Expected active text editor!'); + } + + return editor; +} + +// function pathExists(path: string): boolean { +// return fs.existsSync(path); +// } + +export { + // distinct, + // getWordAtPosition, + tryToGetDocument, + // getDocument, + // getFileType, + // getLaunchingState, + // setLaunchingState, + getConnectedState, + // setConnectedState, + // getConnectingState, + // setConnectingState, + // specialWords, + // ERROR_TYPE, + // logError, + // markError, + // logWarning, + // markWarning, + // logSuccess, + // getCljsReplStartCode, + // getShadowCljsReplStartCode, + // quickPickActive, + // quickPick, + // quickPickSingle, + // quickPickMulti, + // promptForUserInputString, + // filterVisibleRanges, + // scrollToBottom, + // getFileContents, + // jarFilePathComponents, + // getJarContents, + // sortByPresetOrder, + // writeTextToFile, + // downloadFromUrl, + // fetchFromUrl, + cljsLib, + // randomSlug, + // isWindows, + tryToGetActiveTextEditor, + getActiveTextEditor, + // pathExists, + // hyTmpDir, +}; diff --git a/src/when-contexts.ts b/src/when-contexts.ts new file mode 100644 index 0000000..30810cb --- /dev/null +++ b/src/when-contexts.ts @@ -0,0 +1,79 @@ +import * as vscode from 'vscode'; +import * as docMirror from './doc-mirror'; +import * as context from './cursor-doc/cursor-context'; +import * as util from './utilities'; + +let lastContexts: context.CursorContext[] = []; + +function deepEqual(x: any, y: any): boolean { + if (x == y) { + return true; + } + if (x instanceof Array && y instanceof Array) { + if (x.length == y.length) { + for (let i = 0; i < x.length; i++) { + if (!deepEqual(x[i], y[i])) { + return false; + } + } + return true; + } else { + return false; + } + } else if ( + !(x instanceof Array) && + !(y instanceof Array) && + x instanceof Object && + y instanceof Object + ) { + for (const f in x) { + if (!deepEqual(x[f], y[f])) { + return false; + } + } + for (const f in y) { + if (!Object.prototype.hasOwnProperty.call(x, f)) { + return false; + } + } + return true; + } + return false; +} + +export function setCursorContextIfChanged(editor: vscode.TextEditor) { + if ( + !editor || + !editor.document || + editor.document.languageId !== 'hy' || + editor !== util.tryToGetActiveTextEditor() + ) { + return; + } + const currentContexts = determineCursorContexts(editor.document, editor.selection.active); + if (editor.selection.active.line == 0 && editor.selection.active.character == 0) { + delete currentContexts[currentContexts.indexOf("hy:cursorInComment")]; + } + if (!deepEqual(lastContexts, currentContexts)) { + setCursorContexts(currentContexts); + } +} + +function determineCursorContexts( + document: vscode.TextDocument, + position: vscode.Position +): context.CursorContext[] { + const mirrorDoc = docMirror.getDocument(document); + return context.determineContexts(mirrorDoc, document.offsetAt(position)); +} + +function setCursorContexts(currentContexts: context.CursorContext[]) { + lastContexts = currentContexts; + context.allCursorContexts.forEach((context) => { + void vscode.commands.executeCommand( + 'setContext', + context, + currentContexts.indexOf(context) > -1 + ); + }); +} diff --git a/syntaxes/hy.tmLanguage.json b/syntaxes/hy.tmLanguage.json index 04fd3e6..ee731b3 100644 --- a/syntaxes/hy.tmLanguage.json +++ b/syntaxes/hy.tmLanguage.json @@ -73,7 +73,7 @@ }, "symbol": { "name": "variable.other.hy", - "match": "(?*#])[\\.a-zA-Z_\\-=!@\\$%^*#][\\.:\\w_\\-=!@\\$%^&?/<>*#]*" + "match": "(?*#])[\\.a-zA-ZΑ-Ωα-ω_\\-=!@\\$%^*#][\\.:\\w_\\-=!@\\$%^&?/<>*#]*" } }, "scopeName": "source.hy" diff --git a/test/syntax-sample.hy b/test/syntax-sample.hy index 3454e78..439307a 100644 --- a/test/syntax-sample.hy +++ b/test/syntax-sample.hy @@ -15,5 +15,3 @@ (setv dict {:a 1 :b 2 :c 3}) (get dict :a) - - diff --git a/tsconfig.json b/tsconfig.json index ad2a9d4..7257383 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "es2019" ], "sourceMap": true, - "rootDir": ".", + "rootDir": "src", "skipLibCheck": true }, "exclude": [