From 9da6cfc68f0f677fc3caccc16ced44a93af86d04 Mon Sep 17 00:00:00 2001 From: Josh Bax Date: Thu, 5 Dec 2024 11:21:21 -0800 Subject: [PATCH] Add completion feature Squashed commit of the following: commit 479eb4664d9f7325980bd61e344bd7e631aa57fd Author: Josh Bax Date: Tue Dec 3 16:41:38 2024 -0800 Fix: use emacs 28 compat completion fns in test commit 5870d05b9417e3a46f3806cc76089a051842fe6a Author: Josh Bax Date: Tue Dec 3 16:17:33 2024 -0800 Fix: avoid Eask issue to unblock tests commit 19446fd9f146e92500ed511ddd57d42219091f8f Author: Josh Bax Date: Tue Dec 3 10:51:17 2024 -0800 Tidy comments and docstrings commit af87be00c890c506b562eee473d97d71eca1cae8 Author: Josh Bax Date: Tue Dec 3 10:41:40 2024 -0800 Fix do not pass arguments to callback commit ee0824b7ebeb3f7bef8830e368de3b670c2e34fa Author: Josh Bax Date: Tue Dec 3 15:23:16 2024 -0800 Add Capf tests * test/capf-tests.el: C commit 9fcb825af2fe384b822361d1d15ca6f142c7a627 Author: Josh Bax Date: Mon Dec 2 17:50:10 2024 -0800 Move tests into own folder and separate files This allows separate loading of company and CAPE commit bd2616b48575fcb66fb5e438b273f23486ced759 Author: Josh Bax Date: Tue Dec 3 15:17:17 2024 -0800 Native capf commit 5d67b6ba8594214ec963ef5f93cd94892eb8e6c7 Author: Josh Bax Date: Wed Nov 27 11:22:20 2024 -0800 Use string-match-p to support 28 commit 9f1241f22e0eee9f3cbdf52ea391ceee2696b33e Author: Josh Bax Date: Wed Nov 27 11:03:56 2024 -0800 Cleanup hung callbacks when completing props of null object commit 89375779aa38aae4990267187bd54243fd35755f Author: Josh Bax Date: Tue Nov 26 12:45:12 2024 -0800 Quote string when used as regexp in callback commit e276f26555b458790d865205b8ec88fd86de4433 Author: Josh Bax Date: Tue Nov 26 11:46:38 2024 -0800 Address fast-typing bug: extra input when callbacks overlap commit 4353c89f0e263f664c09d531dab1e38ba30aa6f5 Author: Josh Bax Date: Tue Nov 26 10:28:00 2024 -0800 Use ert-async for tests with checks in callbacks Update test-company-complete-long-line commit 44f2eacd1e030d372ef5dfa3c9e1e36e03bec98b Author: Josh Bax Date: Mon Nov 25 12:32:37 2024 -0800 Fix matching bug in complete-substring commit bb479f5f3663a33a59fea97e91bcfcc9e8302bf6 Author: Josh Bax Date: Mon Nov 25 11:34:27 2024 -0800 Remove unused completion string property commit ea7a9fd68810059b4e9e0b51a5885dad8bf23d62 Author: Josh Bax Date: Mon Nov 25 10:28:29 2024 -0800 Fix: minor formatting change commit 2ae491d162e49b6109ec74351f432fa50a873e22 Author: Josh Bax Date: Mon Oct 28 22:35:16 2024 -0700 Cleanup completion buffers commit e07de7bb02114529b5abf962a50431b70002fa80 Author: Josh Bax Date: Mon Oct 28 22:22:11 2024 -0700 Pass NODE_REPL_MODE as argument due to custom env Env var has no effect for some reason commit b649559fb0055a29b5e0b093b0420a21bd618608 Author: Josh Bax Date: Mon Oct 28 22:19:17 2024 -0700 Wiggle the cursor to produce a prompt and prevent hanging This makes most cases resolve with similar output too commit 123c87dec4434a9a6e3aa87f2b26306367325c12 Author: Josh Bax Date: Mon Oct 28 21:23:52 2024 -0700 Add Company integration tests commit 83f8a31b31fbb63780618ce48f20bc81eb79cf6f Author: Josh Bax Date: Mon Oct 28 19:37:40 2024 -0700 Simplify process-completion-output commit f80ca422a6991690489554a710128fa99c0016d1 Author: Josh Bax Date: Mon Oct 28 19:24:50 2024 -0700 Proactively clear partial completions commit 672c28afe896cf8cc60f1c641dbc7b8cae9465e5 Author: Josh Bax Date: Mon Oct 28 16:08:57 2024 -0700 Complete long statements by taking latest substring commit 3e6863ef2f0f9064bdbdae72ad349416a4c984ee Author: Josh Bax Date: Mon Oct 28 15:48:04 2024 -0700 Don't complete at whitespace commit 51ebba7051e852e1e3e7d36e0bdc92d81fa3fd42 Author: Josh Bax Date: Fri Oct 25 14:48:24 2024 -0700 Add unit tests for listener callback code commit 41577969df45572ef4e6d7cc1b9e9a734f117f57 Author: Josh Bax Date: Fri Oct 25 14:42:12 2024 -0700 Recover from errors in company callbacks after completion commit 0ed6d0be08ef334c710a2dcd0ab67d5d95e91098 Author: Josh Bax Date: Fri Oct 25 14:12:50 2024 -0700 Set TERM using env Remove with-environment-variables because it must be set within the comint call. Move other vars into the same location. commit aa3821050361f608469924f6144b33e2f186244c Author: Josh Bax Date: Fri Oct 25 10:22:44 2024 -0700 Failed callbacks should be removed commit 5b3efeaf6bde14e3729e80bea24bfc48e67a367e Author: Josh Bax Date: Fri Oct 25 09:45:53 2024 -0700 Factor out callback active test commit 67208a6cf769d4250342d46e61b6298999f9dbd4 Author: Josh Bax Date: Thu Oct 24 23:11:50 2024 -0700 Add retry counter for method completion commit 9433d55aea03aeea9a9167451425d7a1e4bbcc42 Author: Josh Bax Date: Thu Oct 24 23:11:38 2024 -0700 Factor out callback creation commit dd0a5e5deac9608c692f356b7be9767df2650adc Author: Josh Bax Date: Thu Oct 24 13:08:07 2024 -0700 Rewrite completions to use listener pattern callback commit 83e3509a5d472d3b7b90c42daf394eb218a20813 Author: Josh Bax Date: Thu Oct 24 13:02:45 2024 -0700 Rename company-js-comint-backend => company-js-comint Matches other backend names commit 74592d57755a51e88508f41707cc636bf09a475a Author: Josh Bax Date: Tue Oct 22 23:56:41 2024 -0700 Don't use comint-send-string for invisible input commit 4816ab3dce13bfc40fcf1dc0a880b883d9c247a0 Author: Josh Bax Date: Tue Oct 22 23:50:09 2024 -0700 Inline company definition in should-complete commit c6cbbda5b43028aa6aa995b736dcda93429494ee Author: Josh Bax Date: Tue Oct 22 12:48:22 2024 -0700 Use 28.1 compatible kill-matching-buffers in test commit bb64f539258354b2365a965470a9ca1475e79749 Author: Josh Bax Date: Tue Oct 22 12:36:52 2024 -0700 Add should-complete unit tests Add Eask file commit 0dc206a7be64f61ba8098f22a962be317d115df6 Author: Josh Bax Date: Tue Oct 22 11:23:47 2024 -0700 Add test macro with-new-js-comint-buffer and simplify tests Kills comint buffer between runs for safety commit 054c09781d9c2c1711b4e56b822650cb44169944 Author: Josh Bax Date: Sun Oct 20 11:42:37 2024 -0700 Add unload function to remove company backend commit 96cf5a7d7f45402d67523e52872c5ffed9099f9c Author: Josh Bax Date: Wed Oct 16 16:32:28 2024 -0700 Unit tests for normal completion commit 2721fd884b06eeb06e456bfb78a9c52c5c33d704 Author: Josh Bax Date: Wed Oct 16 16:24:30 2024 -0700 Use js-comint-get-process commit f2f3d65c8729acbf585c3494ab0768d526161276 Author: Josh Bax Date: Wed Oct 16 15:50:56 2024 -0700 Do not take arguments for js-comint--discard-output when a callback commit 7829facfcbf47b90714391df3bc5748ead32e438 Author: Josh Bax Date: Wed Oct 16 15:41:30 2024 -0700 Discard should apply to all output until control char commit 6e274c8fe3c50247a6481b1d6b943e1fd1b8bbc6 Author: Josh Bax Date: Wed Oct 16 15:35:53 2024 -0700 Add unit tests for js-comint--process-completion-output commit 5b50adad45d65b73b5480b8d0b589b965cac068c Author: Josh Bax Date: Wed Oct 16 13:32:57 2024 -0700 Properly complete method names commit 7f57d7618e43ed43e1d7a47b46cac22ed4825b59 Author: Josh Bax Date: Wed Oct 16 11:12:29 2024 -0700 Factor out completion processing logic commit a98dc6e4947a085eacc82f68539ad72638e3f4a3 Author: Josh Bax Date: Wed Oct 16 09:15:20 2024 -0700 Simplify handler logic commit f182a523725c6e62a5c1808c2141c191d7c31acf Author: Josh Bax Date: Tue Oct 15 15:30:25 2024 -0700 Completion handler with debug messages commit 18ff4f64a0426885b6e79cb6a130284a2082cdde Author: Josh Bax Date: Tue Oct 15 13:43:59 2024 -0700 Filter completion output when a flag is set commit 1cfedc9854d4ce6f99a3a14132764ef720737955 Author: Josh Bax Date: Tue Oct 15 10:13:52 2024 -0700 Get input for completion commit 1352dbf197c4cc27ffdd82e57e01cc02171bf367 Author: Josh Bax Date: Tue Oct 15 09:01:49 2024 -0700 Add stub for company-js-comint-backend and init code --- Eask | 6 +- js-comint-test.el | 171 ---------------- js-comint.el | 303 +++++++++++++++++++++++++++- test/capf-tests.el | 77 ++++++++ test/common.el | 33 ++++ test/company-tests.el | 80 ++++++++ test/unit-tests.el | 449 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 939 insertions(+), 180 deletions(-) delete mode 100644 js-comint-test.el create mode 100644 test/capf-tests.el create mode 100644 test/common.el create mode 100644 test/company-tests.el create mode 100644 test/unit-tests.el diff --git a/Eask b/Eask index 86d8442..54dad19 100644 --- a/Eask +++ b/Eask @@ -7,7 +7,7 @@ (package-file "js-comint.el") -(script "test" "eask test ert js-comint-test.el") +(script "test" "eask test ert test/*tests.el") (source "gnu") (source "melpa") @@ -16,4 +16,6 @@ (development (depends-on "el-mock") - (depends-on "nvm")) + (depends-on "nvm") + (depends-on "company") + (depends-on "ert-async")) diff --git a/js-comint-test.el b/js-comint-test.el deleted file mode 100644 index 91b9066..0000000 --- a/js-comint-test.el +++ /dev/null @@ -1,171 +0,0 @@ -;; -*- lexical-binding: t -*- - -(require 'js-comint) -(require 'ert) -(require 'el-mock) - -(defun js-comint-test-buffer-matches (regex) - "Search the js-comint buffer for the given regular expression. -Return 't if a match is found, nil otherwise." - (with-current-buffer (js-comint-get-buffer) - (save-excursion - (goto-char (point-min)) - (if (re-search-forward regex nil t) t nil)))) - -(defun js-comint-test-output-matches (input regex) - "Verify that sending INPUT yields output that matches REGEX." - - ;; Start an instance to run tests on. - (js-comint-reset-repl) - - (sit-for 1) - - (js-comint-send-string input) - - (sit-for 1) - - (js-comint-test-buffer-matches regex)) - -(defun js-comint-test-exit-comint () - "Finish process." - (when (js-comint-get-process) - (process-send-string (js-comint-get-process) ".exit\n") - (sit-for 1))) - -(ert-deftest js-comint-test-multiline-dotchain-line-start () - "Test multiline statement with dots at beginning of lines." - (should (js-comint-test-output-matches "[1, 2, 3] - .map((it) => it + 1) - .filter((it) => it > 0) - .reduce((prev, curr) => prev + curr, 0);" "^9$"))) - -(ert-deftest js-comint-test-multiline-dotchain-line-start-dos () - "Test multiline statement with dots at beginning of lines, with -DOS line separators." - (should (js-comint-test-output-matches "[1, 2, 3]\r - .map((it) => it + 1)\r - .filter((it) => it > 0)\r - .reduce((prev, curr) => prev + curr, 0);\r -" "^9$"))) - -(ert-deftest js-comint-test-multiline-dotchain-line-end () - "Test multiline statement with dots at end of lines." - (should (js-comint-test-output-matches "[1, 2, 3]. -map((it) => it + 1). -filter((it) => it > 0). -reduce((prev, curr) => prev + curr, 0);" "^9$"))) - -(ert-deftest js-comint-start-or-switch-to-repl/test-no-modules () - "Should preserve node_path when nothing is set." - (let ((original js-comint-module-paths) - (original-set-env js-comint-set-env-when-startup) - (original-env (getenv "NODE_PATH"))) - (unwind-protect - (progn - (setq js-comint-module-paths nil - js-comint-set-env-when-startup nil) - (setenv "NODE_PATH" "/foo/bar") - (js-comint-test-exit-comint) - (js-comint-start-or-switch-to-repl) - (sit-for 1) - (js-comint-send-string "process.env['NODE_PATH'];") - (js-comint-test-buffer-matches "/foo/bar")) - (setq js-comint-module-paths original - js-comint-set-env-when-startup original-set-env) - (setenv "NODE_PATH" original-env) - (js-comint-test-exit-comint)))) - -(ert-deftest js-comint-start-or-switch-to-repl/test-global-set () - "Should include the value of `js-comint-node-modules' if set." - (let ((original js-comint-module-paths) - (original-set-env js-comint-set-env-when-startup) - (original-env (getenv "NODE_PATH"))) - (unwind-protect - (progn - (setq js-comint-module-paths '("/baz/xyz") - js-comint-set-env-when-startup nil) - (setenv "NODE_PATH" "/foo/bar") - (js-comint-test-exit-comint) - (js-comint-start-or-switch-to-repl) - (sit-for 1) - (js-comint-send-string "process.env['NODE_PATH'];") - (js-comint-test-buffer-matches (concat "/foo/bar" (js-comint--path-sep) "/baz/xyz"))) - (setq js-comint-module-paths original - js-comint-set-env-when-startup original-set-env) - (setenv "NODE_PATH" original-env) - (js-comint-test-exit-comint)))) - -(ert-deftest js-comint-start-or-switch-to-repl/test-local () - "Should include the optional node-modules-path." - (let ((original js-comint-module-paths) - (original-set-env js-comint-set-env-when-startup) - (original-env (getenv "NODE_PATH")) - (original-suggest (symbol-function 'js-comint--suggest-module-path))) - (unwind-protect - (progn - (fset 'js-comint--suggest-module-path (lambda () "/baz/xyz")) - (setq js-comint-module-paths '() - js-comint-set-env-when-startup 't) - (setenv "NODE_PATH" "/foo/bar") - (js-comint-test-exit-comint) - (js-comint-start-or-switch-to-repl) - (sit-for 1) - (js-comint-send-string "process.env['NODE_PATH'];") - (js-comint-test-buffer-matches (concat "/foo/bar" (js-comint--path-sep) "/baz/xyz"))) - (setq js-comint-module-paths original - js-comint-set-env-when-startup original-set-env) - (setenv "NODE_PATH" original-env) - (fset 'js-comint--suggest-module-path original-suggest) - (js-comint-test-exit-comint)))) - -(ert-deftest js-comint/test-strict-mode () - "When NODE_REPL_MODE=strict should use strict mode." - (with-environment-variables (("NODE_REPL_MODE" "strict")) - ;; global variables are not allowed in strict mode - (js-comint-test-output-matches "foo = 5;" "Uncaught ReferenceError.*"))) - -(ert-deftest js-comint-select-node-version/test-no-nvm () - "Should error if nvm is missing." - (let ((original-command-value js-comint-program-command)) - (with-mock - (mock (require 'nvm) => (error "Cannot open nvm")) - (should-error (js-comint-select-node-version)) - (should-not js-use-nvm) - (should (equal js-comint-program-command - original-command-value))))) - -(ert-deftest js-comint-select-node-version/test-with-arg () - "Should set program-command when called non-interactively." - (let ((original-command-value js-comint-program-command) - (original-use-jvm-value js-use-nvm) - (original-nvm-version js-nvm-current-version)) - (unwind-protect - (with-mock - (mock (require 'nvm)) - (mock (nvm--find-exact-version-for "foo") => '("foo-1.2" "some_path")) - (js-comint-select-node-version "foo") - (should js-use-nvm) - (should (equal js-comint-program-command - "some_path/bin/node")) - (should (equal js-nvm-current-version - '("foo-1.2" "some_path")))) - (setq js-comint-program-command original-command-value - js-use-nvm original-use-jvm-value - js-nvm-current-version original-nvm-version)))) - -(ert-deftest js-comint-select-node-version/test-optional-arg () - "Should set program-command when called with no arg." - (let ((original-command-value js-comint-program-command) - (original-use-jvm-value js-use-nvm) - (original-nvm-version js-nvm-current-version)) - (unwind-protect - (with-mock - (mock (require 'nvm)) - (mock (js-comint-list-nvm-versions *) => "foo") - (mock (nvm--find-exact-version-for "foo") => '("foo-1.2" "some_path")) - (js-comint-select-node-version) - (should (equal js-comint-program-command - "some_path/bin/node"))) - (setq js-comint-program-command original-command-value - js-use-nvm original-use-jvm-value - js-nvm-current-version original-nvm-version)))) diff --git a/js-comint.el b/js-comint.el index 8c6a93e..5905bfa 100644 --- a/js-comint.el +++ b/js-comint.el @@ -85,6 +85,7 @@ (require 'js) (require 'comint) (require 'ansi-color) +(require 'cl-lib) (defgroup js-comint nil "Run a javascript process in a buffer." @@ -140,7 +141,8 @@ "require('repl').start({ \"prompt\": '%s', \"ignoreUndefined\": true, -\"preview\": true +\"preview\": true, +\"replMode\": require('repl')['REPL_MODE_' + '%s'.toUpperCase()] })")) (defvar js-nvm-current-version nil @@ -150,6 +152,11 @@ Either nil or a list (VERSION-STRING PATH).") (declare-function nvm--installed-versions "nvm.el" ()) (declare-function nvm--find-exact-version-for "nvm.el" (short)) +;; company.el declarations +(defvar company-backends) +(declare-function company-begin-backend "company.el" (backend &optional callback)) +(declare-function company-in-string-or-comment "company.el" nil) + (defun js-comint-list-nvm-versions (prompt) "List all available node versions from nvm prompting the user with PROMPT. Return a string representing the node version." @@ -228,6 +235,270 @@ Return a string representing the node version." (delete dir js-comint-module-paths)) (message "\"%s\" delete from `js-comint-module-paths'" dir)))))) +;;;; Completions: +(defun js-comint--process-completion-output (completion prefix) + "Format COMPLETION string as a list of candidates. +PREFIX is the original completion prefix string." + (let* ((completion (replace-regexp-in-string "\\[[[:digit:]]+[[:alpha:]]+" "" completion)) + (completion-lines (split-string completion " \n" 't)) + ;; in a single completion the node REPL optionally prints type information in a comment + (completion-lines (seq-remove (apply-partially #'string-prefix-p "//") + completion-lines)) + (completion-tokens (seq-mapcat (lambda (x) (split-string x nil 't)) completion-lines)) + (trimmed-prompt (string-trim js-comint-prompt)) + (completion-res (seq-remove (lambda (x) (or (equal x prefix) + (equal x trimmed-prompt) + (equal x "..."))) + completion-tokens))) + completion-res)) + +(defvar-local js-comint--completion-callbacks nil + "List of pending callbacks. +Each should be a plist with last-prompt-start, input-string, type, +function. See `js-comint--set-completion-callback'.") + +(defvar-local js-comint--completion-buffer nil + "Buffer for completion output.") + +(defun js-comint--clear-completion-state () + "Clear stored partial completions." + (when js-comint--completion-buffer + (with-current-buffer js-comint--completion-buffer + (erase-buffer)))) + +(defun js-comint--callback-active-p (callback) + "Non-nil if CALLBACK should be used given current prompt location and input." + (and + (equal + ;; marker for the prompt active when callback was created + (plist-get callback :last-prompt-start) + ;; start prompt marker, must be in comint buffer + (car comint-last-prompt)) + ;; check input-string + (or + ;; "clear" callbacks always fire + (equal 'clear (plist-get callback :type)) + ;; otherwise, if inputs are different, then not active + (equal (js-comint--current-input) (plist-get callback :input-string))))) + +(defun js-comint--async-output-filter (output) + "Dispatches callbacks listening for comint OUTPUT." + (cond + ((null js-comint--completion-callbacks) + output) + ;; Assuming most recent callbacks are at head of the list + ;; If the head is not active, then discard all in the list + ((not (js-comint--callback-active-p (car js-comint--completion-callbacks))) + (prog1 output + (js-comint--clear-completion-state) + (setq js-comint--completion-callbacks nil))) + (t + (prog1 "" + ;; first write output to completion-buffer + (unless (bufferp js-comint--completion-buffer) + (setq js-comint--completion-buffer (generate-new-buffer " *js-comint completion*" t))) + (with-current-buffer js-comint--completion-buffer + (goto-char (point-max)) + (insert output)) + ;; call only the active ones, discard others + (let ((active-callbacks (seq-filter + #'js-comint--callback-active-p + js-comint--completion-callbacks))) + ;; Some callbacks may add further callbacks during their execution. + ;; Re-add any pending callbacks to avoid overwriting new callbacks. + (setq js-comint--completion-callbacks nil) + (dolist (cb active-callbacks) + ;; if the callback exits with non-nil or signals, remove it + (unless (condition-case err + (funcall (plist-get cb :function)) + (t (prog1 t + (message "Error in callback %s" (cdr err))))) + (push cb js-comint--completion-callbacks)))) + (if js-comint--completion-callbacks + (accept-process-output (js-comint-get-process) 0.4) + ;; otherwise reset + (js-comint--clear-completion-state)))))) + +(defun js-comint--completion-looking-back-p (regexp) + "Call `looking-back' with REGEXP on `js-comint--completion-buffer'." + (with-current-buffer js-comint--completion-buffer + (goto-char (point-max)) + (looking-back regexp (line-beginning-position)))) + +(defun js-comint--set-completion-callback (callback type) + "Add CALLBACK to listen for completion output. +TYPE is a symbol describing the callback, either \"clear\" or \"completion\"." + (push `(:last-prompt-start + ,(car comint-last-prompt) + :input-string + ,(js-comint--current-input) + :type + ,type + :function + ,callback) + js-comint--completion-callbacks)) + +(defun js-comint--complete-substring (input-string) + "Given a full line in INPUT-STRING return the substring to complete." + (if-let ((match (string-match-p "[[:space:](\\[{;]\\([[:word:].]*\\)$" input-string))) + (string-trim (substring-no-properties input-string (1+ match))) + input-string)) + +(defun js-comint--get-completion-async (input-string callback) + "Complete INPUT-STRING and register CALLBACK to recieve completion output." + (js-comint--clear-completion-state) + (js-comint--set-completion-callback + ;; callback closure + (let (tab-sent ;; flag tracking repeat tabs: Array\t => \t + check-sent ;; flag tracking whether check has been sent + finished) ;; flag tracking if 'callback' has been called, referred to by inner closures + (lambda () + (cond + ;; case: exact match to input-string in output + ((and (not (string-empty-p input-string)) + (js-comint--completion-looking-back-p (regexp-quote input-string))) + ;; Completions like "Array." need a second tab after the response + (cond + ((and (string-suffix-p "." input-string) + (not tab-sent)) + (prog1 nil ;; do not remove callback + (setq tab-sent 't) + ;; When completing "Array." this will get a list of props + (process-send-string (js-comint-get-process) "\t") + ;; When the completion does not exist, e.g. "foo." there is no response to this tab + ;; Instead, schedule a callback that produces a prompt, e.g. "> foo." + ;; This might happen after completion is finished, either by other input or clearing, + ;; so check to avoid adding garbage output. + (run-at-time 1 nil (lambda () (unless (or finished (not js-comint--completion-callbacks)) + (process-send-string (js-comint-get-process) " \b")))))) + ;; Output may sometimes be staggered "Arr|ay" so the completion appears stepwise + ((not check-sent) + (prog1 nil ;; do not remove callback + (setq check-sent 't) + ;; This "wiggles" the cursor making node repeat the prompt with current input + (process-send-string (js-comint-get-process) " \b"))) + ;; Otherwise there was no match (after retry). + ;; The wiggle should cause a prompt to be echoed, so this case likely does not occur. + ;; Probably useful to keep for edge cases though. + ('t + (prog1 't ;; remove callback + (unwind-protect + (funcall callback nil) + (js-comint--clear-input-async)))))) + ;; case: found a control character (usually part of a prompt) + ((or check-sent + (js-comint--completion-looking-back-p "\\[[[:digit:]]+[AJG]$")) + (setq finished 't) + (let* ((completion-output (with-current-buffer js-comint--completion-buffer + (buffer-string))) + (completion-res (js-comint--process-completion-output + completion-output + input-string))) + (unwind-protect + (funcall callback completion-res) + (js-comint--clear-input-async)) + 't)) + ;; all other cases + ('t + ;; expect that the callback will be removed + nil)))) + 'completion) + + ;; Need to send 2x tabs to trigger completion when there is no input + ;; 1st tab usually does common prefix + (when (string-empty-p input-string) + (process-send-string + (js-comint-get-process) + "\t")) + + (process-send-string + (js-comint-get-process) + (format "%s\t" input-string))) + +(defun js-comint--clear-input-async () + "Clear input already sent to the REPL. +This is used specifically to remove input used to trigger completion." + (js-comint--set-completion-callback + (lambda () + ;; mask output until the REPL echoes a prompt + (js-comint--completion-looking-back-p (concat js-comint-prompt "\\[[[:digit:]]+[AG]$"))) + 'clear) + + (process-send-string + (js-comint-get-process) + ;; this is the symbol for "cut" + "")) + +(defun js-comint--current-input () + "Return current comint input relative to point. +Nil if point is before the current prompt." + (let ((pmark (process-mark (js-comint-get-process)))) + (when (>= (point) (marker-position pmark)) + (buffer-substring pmark (point))))) + +(defun js-comint--should-complete () + "Non-nil if completion should be attempted on text before point." + (let* ((parse (syntax-ppss)) + (string-or-comment (or (nth 3 parse) (nth 4 parse) (nth 7 parse)))) + (cond + (string-or-comment + nil) + ((looking-back "\\." (line-beginning-position)) + 't) + ((looking-back "[[:punct:][:space:]]" (line-beginning-position)) + nil) + (t + 't)))) + +;;;###autoload +(defun company-js-comint (command &optional arg &rest _ignored) + "Wraps node REPL completion for company." + (interactive (list 'interactive)) + (cl-case command + ((interactive) + (company-begin-backend 'company-js-comint)) + ((prefix) + (when (equal major-mode 'js-comint-mode) + (if (js-comint--should-complete) + (js-comint--complete-substring (js-comint--current-input)) + 'stop))) + ((candidates) + (cons :async (apply-partially #'js-comint--get-completion-async arg))))) + +(with-eval-after-load 'company + (cl-pushnew #'company-js-comint company-backends)) + +;; loosely follows cape-company-to-capf +(defun js-comint--capf () + "Convert `company-js-comint' function to a completion-at-point-function." + (when-let ((_ (js-comint--should-complete)) + (initial-input (js-comint--complete-substring (js-comint--current-input)))) + (let* ((end (point)) + (beg (- end (length initial-input))) + restore-props) + (list beg end + (completion-table-dynamic + (lambda (input) + (let ((cands 'js-comint--waiting)) + (js-comint--get-completion-async input (lambda (arg) (setq cands arg))) + ;; Force synchronization, not interruptible! We use polling + ;; here and ignore pending input since we don't use + ;; `sit-for'. This is the same method used by Company itself. + (while (eq cands 'js-comint--waiting) + (sleep-for 0.01)) + ;; The candidate string including text properties should be + ;; restored in the :exit-function + (setq restore-props cands) + (cons (apply-partially #'string-prefix-p input) cands))) + t) + :exclusive 'no + :exit-function (lambda (x _status) + ;; Restore the candidate string including + ;; properties if restore-props is non-nil. See + ;; the comment above. + (setq x (or (car (member x restore-props)) x)) + nil))))) + ;;;###autoload (defun js-comint-save-setup () "Save current setup to \".dir-locals.el\"." @@ -312,12 +583,16 @@ Create a new Javascript REPL process." (all-paths-list (seq-remove 'string-empty-p all-paths-list)) (local-node-path (string-join all-paths-list (js-comint--path-sep))) (js-comint-code (format js-comint-code-format - (window-width) js-comint-prompt))) - (with-environment-variables (("NODE_NO_READLINE" "1") - ("NODE_PATH" local-node-path)) - (pop-to-buffer - (apply 'make-comint js-comint-buffer js-comint-program-command nil - `(,@js-comint-program-arguments "-e" ,js-comint-code)))) + (window-width) + js-comint-prompt + (or (getenv "NODE_REPL_MODE") "sloppy"))) + ;; NOTE: it's recommended not to use NODE_PATH + (environment `("TERM=emacs" + "NODE_NO_READLINE=1" + ,(format "NODE_PATH=%s" local-node-path)))) + (pop-to-buffer + (apply 'make-comint js-comint-buffer "env" nil + `(,@environment ,js-comint-program-command ,@js-comint-program-arguments "-e" ,js-comint-code))) (js-comint-mode)))) ;;;###autoload @@ -422,6 +697,16 @@ If no region selected, you could manually input javascript expression." (define-key map (kbd "C-c C-c") 'js-comint-quit-or-cancel) map)) +(defun js-comint-unload-function () + "Cleanup mode settings." + (when company-backends + (setq company-backends + (delete #'company-js-comint company-backends)))) + +(defun js-comint--cleanup () + "Runs after comint buffer is killed." + (when js-comint--completion-buffer + (kill-buffer js-comint--completion-buffer))) ;;;###autoload (define-derived-mode js-comint-mode comint-mode "Javascript REPL" @@ -433,6 +718,10 @@ If no region selected, you could manually input javascript expression." ;; Ignore duplicates (setq comint-input-ignoredups t) (add-hook 'comint-output-filter-functions 'js-comint-filter-output nil t) + (add-hook 'comint-preoutput-filter-functions #'js-comint--async-output-filter nil t) + (add-hook 'kill-buffer-hook #'js-comint--cleanup nil t) + (unless (featurep 'company) + (add-hook 'completion-at-point-functions #'js-comint--capf nil t)) (process-put (js-comint-get-process) 'adjust-window-size-function (lambda (_process _windows) ())) (use-local-map js-comint-mode-map) diff --git a/test/capf-tests.el b/test/capf-tests.el new file mode 100644 index 0000000..7c9c70c --- /dev/null +++ b/test/capf-tests.el @@ -0,0 +1,77 @@ +;; -*- lexical-binding: t -*- + +(require 'js-comint) +(require 'ert) +(require 'ert-async) + +(load-file "./test/common.el") + +;;; Integration tests for native completion + +;; quick fix for eask bug that improperly fails tests which use minibuffer +;; see https://github.com/emacs-eask/cli/pull/286 +;; can be removed once fix is available in CI images +(when (advice-member-p 'eask-test-ert--message 'message) + (message "override advice") + (define-advice message (:before-until (&rest args)) + (not (car args)))) + +(ert-deftest js-comint/test-capf-init () + "When company is not loaded should use CAPF." + (when (featurep 'company) + (unload-feature 'company 't)) + (with-new-js-comint-buffer + (should-not (equal completion-at-point-functions + (default-value 'completion-at-point-functions))))) + +(ert-deftest js-comint/test-capf-unique-result () + "Completing with a unique result. +E.g. Arr => Array, or conso => console." + (when (featurep 'company) + (unload-feature 'company 't)) + (with-new-js-comint-buffer + (sit-for 1) + (insert "Arra") + (completion-at-point) + (should (looking-back "Array")))) + +;; note that completion-at-point does not seem to work for an empty prompt + +(ert-deftest js-comint/test-capf-complete-props () + "Completing props of an object. +E.g. should complete \"Array.\" to all properties." + (when (featurep 'company) + (unload-feature 'company 't)) + (with-new-js-comint-buffer + (sit-for 1) + (insert "Array.") + (completion-at-point) + (with-selected-window (get-buffer-window "*Completions*") + (next-completion 1) + (choose-completion)) + (should (looking-back "Array.__proto__")))) + +(ert-deftest js-comint/test-capf-complete-long-line () + "Completing part of a line. +E.g. 'if (true) { console.'" + (when (featurep 'company) + (unload-feature 'company 't)) + (with-new-js-comint-buffer + (sit-for 1) + (insert "if (true) { console.") + (completion-at-point) + (with-selected-window (get-buffer-window "*Completions*") + (next-completion 1) + (choose-completion)) + (should (looking-back "console.__proto__")))) + +(ert-deftest-async js-comint/test-capf-no-props (done) + "Completing a string with trailing dot should not hang." + (when (featurep 'company) + (unload-feature 'company 't)) + (with-new-js-comint-buffer + (sit-for 1) + (insert "foo.") + (should-not (completion-at-point)) + ;; detects if completion-at-point hangs + (funcall done))) diff --git a/test/common.el b/test/common.el new file mode 100644 index 0000000..16d8a6b --- /dev/null +++ b/test/common.el @@ -0,0 +1,33 @@ +;; -*- lexical-binding: t -*- + +(require 'js-comint) +(require 'ert) + +(defmacro with-new-js-comint-buffer (&rest body) + "Run BODY with a fresh js-comint as current buffer and exit after." + (declare (indent 0) (debug t)) + `(progn + (when (js-comint-get-process) + (kill-process (js-comint-get-process))) + (sleep-for 0.2) + (kill-matching-buffers (js-comint-get-buffer-name) nil t) + (run-js) + (unwind-protect + (with-current-buffer (js-comint-get-buffer) + (font-lock-mode -1) + (sit-for 1) ;; prevent race condition on start + ,@body) + (when (js-comint-get-process) + (kill-process (js-comint-get-process))) + (sleep-for 0.2) + (kill-matching-buffers (js-comint-get-buffer-name) nil t)))) + +(defun js-comint-test-output-matches (input regex) + "Verify that sending INPUT yields output that matches REGEX." + (with-new-js-comint-buffer + (js-comint-send-string input) + (sit-for 1) + (let ((output (buffer-substring-no-properties + comint-last-input-end + (car comint-last-prompt)))) + (should (string-match-p regex output))))) diff --git a/test/company-tests.el b/test/company-tests.el new file mode 100644 index 0000000..8f403af --- /dev/null +++ b/test/company-tests.el @@ -0,0 +1,80 @@ +;; -*- lexical-binding: t -*- + +(require 'js-comint) +(require 'ert) +(require 'company) + +(load-file "./test/common.el") + +;;; Company Integration Tests + +;; sanity check: node should interpret ^U +(ert-deftest js-comint--clear-input-async/test-integration () + "Tests whether clear works in a live comint." + (with-new-js-comint-buffer + (process-send-string (get-buffer-process (current-buffer)) "5") + (js-comint--clear-input-async) + (comint-send-input) + (let ((output (buffer-substring-no-properties + comint-last-input-end + (car comint-last-prompt)))) + ;; if it fails, node will see 5^U and fail, or see just 5 and echo it + (should (string-empty-p output))))) + +(ert-deftest js-comint/test-dumb-term () + "TERM env var should not be dumb." + (js-comint-test-output-matches "process.env['TERM']" "emacs")) + +(ert-deftest js-comint/test-company-global () + "Tests completion with an empty prompt." + (with-new-js-comint-buffer + (company-mode) + (sit-for 1) + (company-manual-begin) + ;; register callback to see globals + (company-complete-selection) + (should (looking-back "AbortController")))) + +(ert-deftest js-comint/test-company-unique-result () + "Completing with a unique result. +E.g. Arr => Array, or conso => console." + (with-new-js-comint-buffer + (company-mode) + (setq company-async-timeout 5) + (sit-for 1) + (insert "Arra") + (company-complete) + (should (looking-back "Array")))) + +(ert-deftest js-comint/test-company-complete-props () + "Completing props of an object. +E.g. should complete \"Array.\" to all properties." + (with-new-js-comint-buffer + (company-mode) + (sit-for 1) + (insert "Array.") + (company-manual-begin) + (company-complete-selection) + (should (looking-back "Array.__proto__")))) + +(ert-deftest js-comint/test-company-complete-long-line () + "Completing part of a line. +E.g. 'if (true) { console.'" + (with-new-js-comint-buffer + (company-mode) + (sit-for 1) + (insert "if (true) { console.") + (company-manual-begin) + (company-complete-selection) + (should (looking-back "console.__proto__")))) + +(ert-deftest js-comint/test-company-quick-typing () + "When completion is triggered while one is already running." + (with-new-js-comint-buffer + (company-mode) + (sit-for 1) + (insert "scrog.") + (company-manual-begin) + (insert "foo") + (company-manual-begin) + (should (looking-back "scrog.foo")))) diff --git a/test/unit-tests.el b/test/unit-tests.el new file mode 100644 index 0000000..6ac2623 --- /dev/null +++ b/test/unit-tests.el @@ -0,0 +1,449 @@ +;; -*- lexical-binding: t -*- + +(require 'js-comint) +(require 'ert) +(require 'el-mock) +(require 'ert-async) + +(load-file "./test/common.el") + +(ert-deftest js-comint-test-multiline-dotchain-line-start-dos () + "Test multiline statement with dots at beginning of lines, with +DOS line separators." + (js-comint-test-output-matches + "[1, 2, 3]\r + .map((it) => it + 1)\r + .filter((it) => it > 0)\r + .reduce((prev, curr) => prev + curr, 0);\r +" + ;; output + "^9$")) + +(ert-deftest js-comint-test-multiline-dotchain-line-end () + "Test multiline statement with dots at end of lines." + (js-comint-test-output-matches + "[1, 2, 3]. +map((it) => it + 1). +filter((it) => it > 0). +reduce((prev, curr) => prev + curr, 0);" + ;; output + "^9$")) + +(ert-deftest js-comint-start-or-switch-to-repl/test-no-modules () + "Should preserve node_path when nothing is set." + (let ((original js-comint-module-paths) + (original-set-env js-comint-set-env-when-startup) + (original-env (getenv "NODE_PATH"))) + (unwind-protect + (progn + (setq js-comint-module-paths nil + js-comint-set-env-when-startup nil) + (setenv "NODE_PATH" "/foo/bar") + (js-comint-test-output-matches "process.env['NODE_PATH'];" + "/foo/bar")) + (setq js-comint-module-paths original + js-comint-set-env-when-startup original-set-env) + (setenv "NODE_PATH" original-env)))) + +(ert-deftest js-comint-start-or-switch-to-repl/test-global-set () + "Should include the value of `js-comint-node-modules' if set." + (let ((original js-comint-module-paths) + (original-set-env js-comint-set-env-when-startup) + (original-env (getenv "NODE_PATH"))) + (unwind-protect + (progn + (setq js-comint-module-paths '("/baz/xyz") + js-comint-set-env-when-startup nil) + (setenv "NODE_PATH" "/foo/bar") + (js-comint-test-output-matches "process.env['NODE_PATH'];" + (concat "/foo/bar" (js-comint--path-sep) "/baz/xyz"))) + (setq js-comint-module-paths original + js-comint-set-env-when-startup original-set-env) + (setenv "NODE_PATH" original-env)))) + +(ert-deftest js-comint-start-or-switch-to-repl/test-local () + "Should include the optional node-modules-path." + (let ((original js-comint-module-paths) + (original-set-env js-comint-set-env-when-startup) + (original-env (getenv "NODE_PATH")) + (original-suggest (symbol-function 'js-comint--suggest-module-path))) + (unwind-protect + (progn + (fset 'js-comint--suggest-module-path (lambda () "/baz/xyz")) + (setq js-comint-module-paths '() + js-comint-set-env-when-startup 't) + (setenv "NODE_PATH" "/foo/bar") + (js-comint-test-output-matches "process.env['NODE_PATH'];" + (concat "/foo/bar" (js-comint--path-sep) "/baz/xyz"))) + (setq js-comint-module-paths original + js-comint-set-env-when-startup original-set-env) + (setenv "NODE_PATH" original-env) + (fset 'js-comint--suggest-module-path original-suggest)))) + +(ert-deftest js-comint/test-strict-mode () + "When NODE_REPL_MODE=strict should use strict mode." + (with-environment-variables (("NODE_REPL_MODE" "strict")) + ;; global variables are not allowed in strict mode + (js-comint-test-output-matches "foo = 5;" "Uncaught ReferenceError.*"))) + +(ert-deftest js-comint-select-node-version/test-no-nvm () + "Should error if nvm is missing." + (let ((original-command-value js-comint-program-command)) + (with-mock + (mock (require 'nvm) => (error "Cannot open nvm")) + (should-error (js-comint-select-node-version)) + (should-not js-use-nvm) + (should (equal js-comint-program-command + original-command-value))))) + +(ert-deftest js-comint-select-node-version/test-with-arg () + "Should set program-command when called non-interactively." + (let ((original-command-value js-comint-program-command) + (original-use-jvm-value js-use-nvm) + (original-nvm-version js-nvm-current-version)) + (unwind-protect + (with-mock + (mock (require 'nvm)) + (mock (nvm--find-exact-version-for "foo") => '("foo-1.2" "some_path")) + (js-comint-select-node-version "foo") + (should js-use-nvm) + (should (equal js-comint-program-command + "some_path/bin/node")) + (should (equal js-nvm-current-version + '("foo-1.2" "some_path")))) + (setq js-comint-program-command original-command-value + js-use-nvm original-use-jvm-value + js-nvm-current-version original-nvm-version)))) + +(ert-deftest js-comint-select-node-version/test-optional-arg () + "Should set program-command when called with no arg." + (let ((original-command-value js-comint-program-command) + (original-use-jvm-value js-use-nvm) + (original-nvm-version js-nvm-current-version)) + (unwind-protect + (with-mock + (mock (require 'nvm)) + (mock (js-comint-list-nvm-versions *) => "foo") + (mock (nvm--find-exact-version-for "foo") => '("foo-1.2" "some_path")) + (js-comint-select-node-version) + (should (equal js-comint-program-command + "some_path/bin/node"))) + (setq js-comint-program-command original-command-value + js-use-nvm original-use-jvm-value + js-nvm-current-version original-nvm-version)))) + +(ert-deftest js-comint--current-input/test () + "Tests default behavior." + (with-new-js-comint-buffer + (insert "Array") + (should (equal (js-comint--current-input) "Array")) + (comint-send-input) + (should (equal (js-comint--current-input) "")) + (goto-char (point-min)) + (should (equal (js-comint--current-input) nil)))) + +(ert-deftest js-comint--complete-substring/test-semi-colon () + "Should break at semi-colons." + (should (equal (js-comint--complete-substring "foo; bar") + "bar"))) + +(ert-deftest js-comint--complete-substring/test-brackets () + "Should break at opening brackets." + (should (equal (js-comint--complete-substring "if(tru") + "tru"))) + +(ert-deftest js-comint--complete-substring/test-words () + "Should break at words." + (should (equal (js-comint--complete-substring "for (let i of myObject.pro") + "myObject.pro"))) + +(ert-deftest js-comint--complete-substring/test-braces () + "Should break at opening braces." + (should (equal (js-comint--complete-substring "if (true) { cons") + "cons"))) + +(ert-deftest js-comint--should-complete/test () + "Tests default behavior." + (with-new-js-comint-buffer + (insert "Arr") + (should (js-comint--should-complete)) + (comint-kill-input) + + (insert "Array.") + (should (js-comint--should-complete)) + (comint-kill-input) + + ;; empty line + (should (js-comint--should-complete)) + + (insert "// a comment") + (should-not (js-comint--should-complete)) + (comint-kill-input) + + (insert "\"foo") + (should-not (js-comint--should-complete)) + (comint-kill-input) + + (insert "[1,2,") + (should-not (js-comint--should-complete)) + (comint-kill-input) + + (insert "foo() ") + (should-not (js-comint--should-complete)) + (comint-kill-input))) + +(ert-deftest js-comint--process-completion-output/test-globals () + "Completing an empty string." + (should + (equal + (js-comint--process-completion-output + " +AbortController AbortSignal AggregateError Array + +constructor + +> " + "") + '("AbortController" + "AbortSignal" + "AggregateError" + "Array" + "constructor")))) + +(ert-deftest js-comint--process-completion-output/test-single-completion () + "Completion of \"Arr\" yields a single result and type info." + (should (equal (js-comint--process-completion-output + "Array +// [Function: Array]" + "Arr") + '("Array")))) + +(ert-deftest js-comint--process-completion-output/test-method-completion () + "Completion of object properties should give list of properties prefixed with name." + (should + (equal + (js-comint--process-completion-output + "Array. +Array.__proto__ Array.hasOwnProperty Array.isPrototypeOf Array.propertyIsEnumerable Array.toLocaleString +Array.valueOf + +> Array." + "Array.") + '("Array.__proto__" + "Array.hasOwnProperty" + "Array.isPrototypeOf" + "Array.propertyIsEnumerable" + "Array.toLocaleString" + "Array.valueOf")))) + +(ert-deftest js-comint--process-completion-output/test-multiline-prefix () + "Completion when there is a '...' prefix." + (should + (equal + (js-comint--process-completion-output + "Array. +Array.__proto__ Array.hasOwnProperty Array.isPrototypeOf Array.propertyIsEnumerable Array.toLocaleString +Array.valueOf + +... Array." + "Array.") + '("Array.__proto__" + "Array.hasOwnProperty" + "Array.isPrototypeOf" + "Array.propertyIsEnumerable" + "Array.toLocaleString" + "Array.valueOf")))) + +(ert-deftest js-comint--async-output-filter/test-no-callbacks () + "Output should be kept when no callbacks are active." + (with-temp-buffer + (should (equal (js-comint--async-output-filter "foo") + "foo")) + ;; should be the same when old callbacks are used + (with-mock + (mock (js-comint--callback-active-p *) => nil) + (setq js-comint--completion-buffer nil + js-comint--completion-callbacks (list ())) + (should (equal (js-comint--async-output-filter "foo") + "foo")) + ;; callbacks should be cleared too + (should-not js-comint--completion-callbacks)))) + +(ert-deftest js-comint--async-output-filter/test-discard () + "Output should be discarded when completion callback is active." + (with-temp-buffer + ;; function ignore always returns nil, so it is never cleared + (setq js-comint--completion-callbacks (list '(:function ignore))) + (with-mock + (stub js-comint--callback-active-p => 't) + (dolist (output '("foo" "bar" "")) + (should (string-empty-p (js-comint--async-output-filter output))))) + ;; text should be in completion buffer + (should (equal (with-current-buffer js-comint--completion-buffer (buffer-string)) + "foobar")) + (should (equal js-comint--completion-callbacks + (list '(:function ignore)))))) + +(ert-deftest js-comint--async-output-filter/test-callback-error () + "Callback should be removed if it signals an error." + (with-temp-buffer + (setq js-comint--completion-callbacks + (list (list :function (lambda () (error "This should be caught!"))))) + (with-mock + (stub js-comint--callback-active-p => 't) + (dolist (output '("foo" "bar" "")) + (js-comint--async-output-filter output))) + ;; callback should be removed + (should-not js-comint--completion-callbacks))) + +(ert-deftest js-comint--async-output-filter/test-callback-chain () + "Callbacks should be able to add further callbacks." + (with-temp-buffer + (setq js-comint--completion-callbacks + (list (list :function (lambda () + (push '(:function foo) js-comint--completion-callbacks) + 't)))) + (with-mock + (stub js-comint--callback-active-p => 't) + (should (string-empty-p (js-comint--async-output-filter "foo")))) + ;; new callback should be added + (should (equal js-comint--completion-callbacks + (list '(:function foo)))))) + +(ert-deftest js-comint--clear-input-async/test () + "Should send correct clear command and complete on expected response." + (with-temp-buffer + (setq js-comint--completion-callbacks + (list '(:function always))) + (with-mock + (stub js-comint--callback-active-p => 't) + (stub js-comint--current-input => "") + (mock (process-send-string * "")) + (js-comint--async-output-filter "foo") + ;; node input is "foo" + ;; completion buffer is empty as there are no active callbacks + (js-comint--clear-input-async) + ;; term sends a prompt + (should (string-empty-p (js-comint--async-output-filter "> "))) + ;; buffer should be empty and no active callbacks + (should (js-comint--completion-looking-back-p "^$")) + (should-not js-comint--completion-callbacks)))) + +(ert-deftest-async js-comint--async-output-filter/test-no-completion (done) + "Output should be saved until string match, then fail." + (with-mock + (stub js-comint--callback-active-p => 't) + (stub js-comint--current-input => "foo") + ;; 1 - complete foo + ;; 2 - finished test " \b" + ;; 3 - clear "" + (mock (process-send-string * *) :times 3) + (with-temp-buffer + (js-comint--get-completion-async "foo" + (lambda (arg) + ;; callback should be called with nil + (funcall done (when arg + (format "expected %s to be nil" arg))))) + (dolist (output '("f" "oo" ;; output in chunks + " > foo" ;; response to " \b" + )) + (should (string-empty-p (js-comint--async-output-filter output)))) + ;; clear should be called + (should (equal (plist-get (car js-comint--completion-callbacks) :type) + 'clear))))) + +(ert-deftest-async js-comint--async-output-filter/test-stale-callback (done) + "Callback should not be called if input has changed." + (with-new-js-comint-buffer + (with-mock + ;; input is "Array" when callback is set + (mock (js-comint--current-input) => "Array") + (js-comint--get-completion-async "Array" + (lambda (arg) + ;; callback should be called with nil or not called + (funcall done (when arg + (format "expected %s to be nil" arg)))))) + ;; bump output, input is now "" + (js-comint--async-output-filter "") + ;; async-output-filter should discard the previous callback as it doesn't apply + (funcall done (when js-comint--completion-callbacks + (format "expected %s to be nil" js-comint--completion-callbacks))))) + +(ert-deftest-async js-comint--get-completion-async/test-prop-completion-fail (done) + "When completion fails on something that looks like an object don't hang." + (with-mock + (stub js-comint--callback-active-p => 't) + (stub js-comint--current-input => "scrog.") + ;; 1 - complete + ;; 2 - send another \t + ;; 3 - finished test " \b" + ;; 4 - clear + (mock (process-send-string * *) :times 4) + (with-temp-buffer + (js-comint--get-completion-async "scrog." + (lambda (arg) + ;; callback should be called with nil + (funcall done (when arg + (format "expected %s to be nil" arg))))) + (dolist (output '("s" "crog.")) + (should (string-empty-p (js-comint--async-output-filter output)))) + ;; no response to repeat \t + ;; after some delay, send answer to wiggle + (sleep-for 2) + (should (string-empty-p (js-comint--async-output-filter " > scrog.")))))) + +(ert-deftest js-comint--get-completion-async/test-user-callback-error () + "Should clear even if supplied callback errors." + (with-mock + (stub js-comint--callback-active-p => 't) + (stub js-comint--current-input => "foo") + (stub process-send-string) + (with-temp-buffer + ;; callback errors + (js-comint--get-completion-async "foo" + (lambda (arg) (error "Broken user callback"))) + ;; after output the erroring callback is called with nil + (dolist (output '("f" "oo" + " > foo" ;; response to " \b" + )) + (should (string-empty-p (js-comint--async-output-filter output)))) + ;; clear should be called + (should (equal (plist-get (car js-comint--completion-callbacks) :type) + 'clear))))) + +(ert-deftest js-comint--get-completion-async/test-user-callback-error-2 () + "Should clear even if supplied callback errors (multiple completions)." + (with-mock + (stub js-comint--callback-active-p => 't) + (stub js-comint--current-input => "foo") + (stub process-send-string) + (with-temp-buffer + ;; callback errors + (js-comint--get-completion-async "foo" + (lambda (arg) (error "Broken user callback"))) + ;; after output the erroring callback is called with nil + (dolist (output '("foo bar baz \n> foo")) + (should (string-empty-p (js-comint--async-output-filter output)))) + ;; clear should be called + (should (equal (plist-get (car js-comint--completion-callbacks) :type) + 'clear))))) + +(ert-deftest-async js-comint--get-completion-async/test-prop-completion (done) + "When completing object properties, send another tab to get completion." + (with-mock + (stub js-comint--callback-active-p => 't) + (stub js-comint--current-input => "Array.") + (mock (process-send-string * *) :times 3) + (with-temp-buffer + ;; callback should be called with ("foo" "bar" "baz") + (js-comint--get-completion-async "Array." + (lambda (arg) + (if (equal arg '("foo" "bar" "baz")) + (funcall done) + (funcall done (format "error %s should be '(\"foo\" \"bar\" \"baz\")" arg))))) + ;; after the second tab get completion suggestions + (dolist (output '("A" "rray." " foo bar baz \n> Array.")) + (should (string-empty-p (js-comint--async-output-filter output)))) + ;; clear should be called + (should (equal (plist-get (car js-comint--completion-callbacks) :type) + 'clear)))))