emacs-jupyter/jupyter-repl.el
Nathaniel Nicandro 250af96ea2 Remove jupyter-request--id, replace with jupyter-request-id
Since IDs of requests are generated in the parent Emacs process, there is no
need to pass back the ID of a request from the child process that send
messages.
2018-10-25 22:57:23 -05:00

2556 lines
106 KiB
EmacsLisp

;;; jupyter-repl-client.el --- A Jupyter REPL client -*- lexical-binding: t -*-
;; Copyright (C) 2018 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 08 Jan 2018
;; Version: 0.0.1
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 2, or (at
;; your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs; see the file COPYING. If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.
;;; Commentary:
;; A Jupyter REPL for Emacs.
;;
;; The main entry points are `run-jupyter-repl' and `connect-jupyter-repl'.
;;
;; When called interactively, `run-jupyter-repl' asks for a kernel to start
;; (based on the kernels found using `jupyter-available-kernelspecs'), connects
;; a `jupyter-repl-client' to the selected kernel, and pops up a REPL buffer.
;; The main difference of `connect-jupyter-repl' is that it will obtain the
;; kernel's connection info by asking for the JSON file containing it to start
;; connection to a kernel.
;;
;; Additionally, `jupyter-repl-associate-buffer' associates the
;; `current-buffer' with a REPL client appropriate for the buffer's
;; `major-mode'. Associating a buffer with a REPL client enables the minor mode
;; `jupyter-repl-interaction-mode' and, if `company-mode' is installed, enables
;; code completion using the associated REPL client.
;;
;; `jupyter-repl-interaction-mode' adds the following keybindings for
;; interacing a REPL client:
;;
;; C-c C-c `jupyter-repl-eval-line-or-region'
;; C-c C-l `jupyter-repl-eval-file'
;; C-c C-f `jupyter-repl-inspect-at-point'
;; C-c C-r `jupyter-repl-restart-kernel'
;; C-c C-i `jupyter-repl-interrupt-kernel'
;; C-c C-z `jupyter-repl-pop-to-buffer'
;;; Code:
(defgroup jupyter-repl nil
"A Jupyter REPL client"
:group 'jupyter)
(require 'jupyter-base)
(require 'jupyter-client)
(require 'jupyter-widget-client)
(require 'jupyter-kernel-manager)
(require 'shr)
(require 'ring)
(require 'ansi-color)
(declare-function company-begin-backend "company" (backend &optional callback))
(declare-function company-doc-buffer "company" (&optional string))
(declare-function company-grab-symbol-cons "company" (idle-begin-after-re &optional max-len))
(declare-function org-format-latex "org" (prefix &optional beg end dir overlays msg forbuffer processing-type))
(declare-function markdown-link-at-pos "markdown-mode" (pos))
(declare-function markdown-follow-link-at-point "markdown-mode")
;; TODO: Fallbacks for when the language doesn't have a major mode installed.
;; TODO: Define `jupyter-kernel-manager-after-restart-hook' to update the
;; execution count after a restart. More generally, define more ways to hook
;; into differnt events of the client/kernel interaction.
;;; User variables
(defface jupyter-repl-input-prompt
'((((class color) (min-colors 88) (background light))
:foreground "darkseagreen2")
(((class color) (min-colors 88) (background dark))
:foreground "darkolivegreen"))
"Face used for the input prompt."
:group 'jupyter-repl)
(defface jupyter-repl-output-prompt
'((((class color) (min-colors 88) (background light))
:foreground "indianred3")
(((class color) (min-colors 88) (background dark))
:foreground "darkred"))
"Face used for the output prompt."
:group 'jupyter-repl)
(defcustom jupyter-repl-maximum-size 1024
"Maximum number of lines before the buffer is truncated."
:type 'integer
:group 'jupyter-repl)
(defcustom jupyter-repl-maximum-is-complete-timeout 2
"Maximum number of seconds to wait for an is-complete reply.
When no is-complete reply is received from the kernel within this
timeout, the built-in is-complete handler is used."
:type 'integer
:group 'jupyter-repl)
(defcustom jupyter-repl-history-maximum-length 100
"The maximum number of history elements to keep track of."
:type 'integer
:group 'jupyter-repl)
(defcustom jupyter-repl-prompt-margin-width 12
"The width of the margin which displays prompt strings."
:type 'integer
:group 'jupyter-repl)
;;; Implementation
(defclass jupyter-repl-client (jupyter-widget-client)
((buffer
:type (or null buffer)
:initform nil
:documentation "The REPL buffer whose
`jupyter-repl-current-client' is this client.")
(wait-to-clear
:type boolean
:initform nil
:documentation "Whether or not we should wait to clear the
current output of the cell. Set when the kernel sends a
`:clear-output' message.")
(kernel-info
:type json-plist
:initform nil
:documentation "The saved kernel info created when first
initializing this client.")
(execution-state
:type string
:initform "idle"
:documentation "The current state of the kernel. Can be
either \"idle\", \"busy\", or \"starting\".")
(execution-count
:type integer
:initform 1
:documentation "The current execution count of the kernel.")))
(defvar jupyter-repl-lang-buffer nil
"A buffer with the `major-mode' set to the REPL language's `major-mode'.")
(defvar jupyter-repl-current-client nil
"The `jupyter-repl-client' for the `current-buffer'.")
(put 'jupyter-repl-current-client 'permanent-local t)
(defvar jupyter-repl-lang-mode nil
"The `major-mode' corresponding to the kernel's language.")
(defvar jupyter-repl-history nil
"The history of the current Jupyter REPL.")
;; TODO: Proper cleanup of these buffers when done with a client
(defvar jupyter-repl-fontify-buffers nil
"An alist of (MODE . BUFFER) pairs used for fontification.
See `jupyter-repl-fontify-according-to-mode'.")
(defvar-local jupyter-repl-use-builtin-is-complete nil
"Whether or not to send `:is-complete-request's to a kernel.
If a Jupyter kernel does not respond to an is_complete_request,
the buffer local value of this variable is set to t and code in a
cell is considered complete if the last line in a code cell is a
blank line, i.e. if RET is pressed twice in a row.")
(defvar jupyter-repl-display-ids nil
"A hash table of display IDs.
Display IDs are implemented by setting the text property,
`jupyter-display', to the display ID requested by a
`:display-data' message. When a display is updated from an
`:update-display-data' message, the display ID from the initial
`:display-data' message is retrieved from this table and used to
find the display in the REPL buffer. See
`jupyter-repl-update-display'.")
;;; Macros
(defmacro with-jupyter-repl-buffer (client &rest body)
"Switch to CLIENT's buffer before running BODY.
This switches to CLIENT's buffer slot, sets `inhibit-read-only'
to t, and then runs BODY. Afterwards, if CLIENT's buffer is
currently being shown in a window, move windows `point' to the
value of `point' in the buffer."
(declare (indent 1) (debug (symbolp &rest form)))
`(with-current-buffer (oref ,client buffer)
(let ((inhibit-read-only t))
(prog1 (progn ,@body)
(let ((win (get-buffer-window)))
(when win (set-window-point win (point))))))))
(defmacro jupyter-repl-without-continuation-prompts (&rest body)
"Run BODY without inserting continuation prompts.
Normally a continuation prompt is inserted for every newline
inserted into the REPL buffer through a function in
`after-change-functions'. Prevent the function from running while
executing BODY."
`(let ((inhibit-modification-hooks t))
,@body))
(defmacro jupyter-repl-do-at-request (client req &rest body)
"Switch to CLIENT's buffer, move to the end of REQ, and run BODY.
REQ is a `jupyter-request' previously made using CLIENT, a
`jupyter-repl-client'.
`point' is moved to the `jupyter-repl-cell-beginning-position' of
the cell *after* REQ, this position is where any newly generated
output of REQ should be inserted."
(declare (indent 2) (debug (symbolp &rest form)))
`(with-jupyter-repl-buffer ,client
(jupyter-repl-without-continuation-prompts
(save-excursion
(jupyter-repl-goto-cell ,req)
(jupyter-repl-next-cell)
,@body))))
(defmacro with-jupyter-repl-lang-buffer (&rest body)
"Run BODY in the `jupyter-repl-lang-buffer' of the `current-buffer'.
The contents of `jupyter-repl-lang-buffer' is erased before
running BODY."
(declare (indent 0) (debug (&rest form)))
`(with-current-buffer jupyter-repl-lang-buffer
(let ((inhibit-read-only t))
(erase-buffer)
,@body)))
(defmacro with-jupyter-repl-cell (&rest body)
"Narrow to the current cell, run BODY, then widen.
The cell is narrowed to the region between and including
`jupyter-repl-cell-code-beginning-position' and
`jupyter-repl-cell-code-end-position'. When BODY is run, `point' will
be at the `jupyter-repl-cell-code-beginning-position'."
(declare (indent 0) (debug (&rest form)))
`(save-excursion
(save-restriction
(narrow-to-region (jupyter-repl-cell-code-beginning-position)
(jupyter-repl-cell-code-end-position))
(goto-char (jupyter-repl-cell-code-beginning-position))
,@body)))
(defun jupyter-repl-get-doc-buffer (name)
"Return the REPL documentation buffer for NAME.
A REPL documentation buffer has the following characteristics:
- `major-mode' is `special-mode'
- local keybindings to quit the window (q), and scroll the
window (SPC and <backtab>).
The buffer returned will have a `buffer-name' of
\"*jupyter-repl-NAME*\". If a buffer with this name already
exists, it is returned."
(let* ((bname (format "*jupyter-repl-%s*" name))
(buffer (get-buffer bname)))
(unless buffer
(setq buffer (get-buffer-create bname))
(with-current-buffer buffer
(special-mode)
(local-set-key "q" #'quit-window)
(local-set-key (kbd "SPC") #'scroll-down)
(local-set-key (kbd "<backtab>") #'scroll-up)))
buffer))
(defmacro with-jupyter-repl-doc-buffer (name &rest body)
"With the REPL documentation buffer corresponding to NAME, run BODY.
NAME should be a string representing the purpose of the
documentation buffer. The buffer corresponding to NAME will be
obtained by a call to `juptyer-repl-get-doc-buffer'. Before
running BODY, the doc buffer is set as the
`other-window-scroll-buffer' and the contents of the buffer are
erased."
(declare (indent 1))
(let ((buffer (make-symbol "buffer")))
`(let ((,buffer (jupyter-repl-get-doc-buffer ,name)))
(setq other-window-scroll-buffer ,buffer)
(with-current-buffer ,buffer
(let ((inhibit-read-only t))
(erase-buffer)
,@body)))))
;;; Convenience functions
(defun jupyter-repl-language-mode (client)
"Return the `major-mode' of CLIENT's kernel language."
(with-jupyter-repl-buffer client
jupyter-repl-lang-mode))
(cl-defmethod jupyter-repl-language ((client jupyter-repl-client))
"Return the name of CLIENT's kernel language."
(plist-get (plist-get (oref client kernel-info) :language_info) :name))
;;; Text insertion
(defun jupyter-repl-add-font-lock-properties (start end &optional object)
"Add font lock text properties between START and END in the `current-buffer'.
START, END, and OBJECT have the same meaning as in
`add-text-properties'. The properties added are the ones that
mark the text between START and END as fontified according to
font lock. Any text between START and END that does not have a
`font-lock-face' property will have the `default' face filled in
for the property."
(add-text-properties
start end '(fontified t font-lock-fontified t font-lock-multiline t) object)
(font-lock-fillin-text-property
start end 'font-lock-face 'default object))
;; Adapted from `org-src-font-lock-fontify-block'
(defun jupyter-repl-fixup-font-lock-properties ()
"Fixup the text properties in the `current-buffer'.
Fixing the text properties of the current buffer involves
substituting any `face' property with `font-lock-face' for
insertion into the REPL buffer and adding
`font-lock-extra-managed-props' to the text."
(let ((start (point-min))
(pos (point-min)) next)
(catch 'done
(while (setq next (or (next-property-change pos) (point-max)))
;; Handle additional properties from font-lock, so as to
;; preserve, e.g., composition.
(dolist (prop (cons 'face font-lock-extra-managed-props))
(let ((new-prop (get-text-property pos prop)))
(put-text-property
(+ start (1- pos)) (1- (+ start next))
(if (eq prop 'face) 'font-lock-face prop)
(if (eq prop 'face) (or new-prop 'default)
new-prop))))
(setq pos next)
(when (= pos (point-max))
(throw 'done t))))))
(defun jupyter-repl-get-fontify-buffer (mode)
"Return the buffer used to fontify text for MODE.
Retrieve the buffer for MODE from `jupyter-repl-fontify-buffers'.
If no buffer for MODE exists, create a new one."
(let ((buf (alist-get mode jupyter-repl-fontify-buffers)))
(unless buf
(setq buf (get-buffer-create
(format " *jupyter-repl-fontify[%s]*" mode)))
(with-current-buffer buf
(funcall mode))
(setf (alist-get mode jupyter-repl-fontify-buffers) buf))
buf))
(defun jupyter-repl-fontify-according-to-mode (mode str)
"Fontify a string according to MODE.
In addition to fontifying STR, if MODE has a non-default
`fill-forward-paragraph-function', STR will be filled using
`fill-region'."
(with-current-buffer (jupyter-repl-get-fontify-buffer mode)
(let ((inhibit-modification-hooks nil))
(erase-buffer)
(insert str)
(font-lock-ensure)
;; FIXME: This adds a font-lock-face of default if text doesn't have a
;; font-lock-face and so does `jupyter-repl-add-font-lock-properties'
(jupyter-repl-fixup-font-lock-properties))
(jupyter-repl-add-font-lock-properties (point-min) (point-max))
(when (not (memq fill-forward-paragraph-function
'(forward-paragraph)))
(fill-region (point-min) (point-max) t 'nosqueeze))
(buffer-string)))
(defun jupyter-repl-insert (&rest args)
"Insert text into the `current-buffer', possibly with text properties.
This acts like `insert' except that the leading elements of ARGS
can contain the following keywords along with their values:
- `:read-only' :: A non-nil value makes the text to be inserted,
read only. This is t by default, so to make text editable you
will have to do something like:
(jupyter-repl-insert :read-only nil \"<editable text>\")
- `:properties' :: A list of text properties and their values to
be added to the inserted text. This defaults to an empty list.
- `:inherit' :: A non-nil value will use
`insert-and-inherit' instead of `insert' for the function used
to insert the text. This is nil by default."
(let ((arg nil)
(read-only t)
(properties nil)
(insert-fun #'insert))
(while (keywordp (setq arg (car args)))
(cl-case arg
(:read-only (setq read-only (cadr args)))
(:properties (setq properties (cadr args)))
(:inherit
(setq insert-fun (if (cadr args) #'insert-and-inherit #'insert)))
(otherwise
(error "Keyword not one of `:read-only', `:properties', `:inherit-' (`%s')" arg)))
(setq args (cddr args)))
(setq properties (append (when read-only '(read-only t))
properties))
(apply insert-fun (mapcar (lambda (s)
(prog1 s
(when properties
(add-text-properties
0 (length s) properties s))))
args))))
(defun jupyter-repl-newline ()
"Insert a read-only newline into the `current-buffer'."
(jupyter-repl-insert "\n"))
;;; Handling rich output
(defvar jupyter-repl-graphic-mimetypes '(:image/png :image/svg+xml :text/latex)
"Mimetypes that display graphics in the REPL buffer.")
(defun jupyter-repl-graphic-data-p (msg)
"Check to see if MSG has mimetypes for graphics."
(cl-loop
with graphic-types = jupyter-repl-graphic-mimetypes
for (mimetype _value) on (jupyter-message-get msg :data) by #'cddr
thereis (memq mimetype graphic-types)))
(defun jupyter-repl-insert-html (html)
"Parse and insert the HTML string using `shr-insert-document'."
(jupyter-repl-insert
;; `save-excursion' is necessary here since it seems that `with-temp-buffer'
;; moves the REPL window's `point' when it is visible
(save-excursion
(with-temp-buffer
(insert html)
(let ((xml (libxml-parse-html-region
(point-min) (point-max))))
(erase-buffer)
(shr-insert-document xml))
(jupyter-repl-fixup-font-lock-properties)
(string-trim (buffer-string))))))
;; Markdown integration
(defvar markdown-hide-markup)
(defvar markdown-hide-urls)
(defvar markdown-fontify-code-blocks-natively)
(defvar jupyter-repl-markdown-mouse-map
(let ((map (make-sparse-keymap)))
(define-key map [return] 'jupyter-repl-markdown-follow-link-at-point)
(define-key map [follow-link] 'mouse-face)
(define-key map [mouse-2] 'jupyter-repl-markdown-follow-link-at-point)
map))
(defun jupyter-repl-markdown-follow-link-at-point ()
"Handle markdown links specially."
(interactive)
(let ((link (markdown-link-at-pos (point))))
;; TODO: How to generalize this to kernels which do not utilize markdown in
;; their docstrings? Maybe if you do M-RET on a symbol it will call the
;; help function on it. For example in the python kernel, you can pull up
;; help on a symbol by calling the help function on it or appending a
;; question mark at the end of the symbol.
(if (and (string= (nth 3 link) "@ref")
(string= (jupyter-repl-language jupyter-repl-current-client)
"julia"))
;; Links have the form `fun`
(let ((fun (substring (nth 2 link) 1 -1)))
(goto-char (point-max))
(jupyter-repl-replace-cell-code (concat "?" fun))
(jupyter-repl-ret))
(markdown-follow-link-at-point))))
(defun jupyter-repl-insert-markdown (text)
"Insert TEXT, fontifying it using `markdown-mode' first."
(let ((pos (point)))
(jupyter-repl-insert
(let ((markdown-hide-markup t)
(markdown-hide-urls t)
(markdown-fontify-code-blocks-natively t))
(jupyter-repl-fontify-according-to-mode 'markdown-mode text)))
;; Update keymaps
(let ((limit (point)) next)
(setq pos (next-single-property-change pos 'keymap nil limit))
(while (/= pos limit)
(setq next (next-single-property-change pos 'keymap nil limit))
(when (eq (get-text-property pos 'keymap) markdown-mode-mouse-map)
(put-text-property pos next 'keymap jupyter-repl-markdown-mouse-map))
(setq pos next)))))
(defvar org-format-latex-options)
(defun jupyter-repl-insert-latex (tex)
"Generate and insert a LaTeX image based on TEX.
Note that this uses `org-format-latex' to generate the LaTeX
image."
;; FIXME: Getting a weird error when killing the temp buffers created by
;; `org-format-latex'. When generating the image, it seems that the temp
;; buffers created have the same major mode and local variables as the REPL
;; buffer which causes the query function to ask to kill the kernel client
;; when the temp buffers are killed!
(let ((kill-buffer-query-functions nil)
(org-format-latex-options
`(:foreground
default
:background default :scale 2.0
:matchers ,(plist-get org-format-latex-options :matchers)))
beg end)
(setq beg (point))
(jupyter-repl-insert tex)
(setq end (point))
(org-format-latex
;; FIXME: Possibly make a resource directory for the REPL
"ltx" beg end org-babel-jupyter-resource-directory
'overlays "Creating LaTeX image...%s"
'forbuffer
;; Use the default method for creating image files
org-preview-latex-default-process)
(goto-char end)))
(defun jupyter-repl-insert-ansi-coded-text (text)
"Insert TEXT, converting ANSI color codes to font lock faces."
(setq text (ansi-color-apply text))
(jupyter-repl-add-font-lock-properties 0 (length text) text)
;; NOTE: Mark text with a specific syntax class so that string characters do
;; not get registered as strings. This requires
;; `parse-sexp-lookup-properties' to be non-nil.
(add-text-properties 0 (length text) '(syntax-table (3)) text)
(jupyter-repl-insert text))
(defun jupyter-repl-insert-data (data metadata)
"Insert DATA into the REPL buffer in order of decreasing richness.
DATA is a plist mapping mimetypes to their content. METADATA is a
plist similar to data, but with values describing extra
information for inserting each kind of mimetype. For example the
value of `image/png' can be a plist with the keys `:width',
`:height'.
Attempt to insert a recognized mimetype, trying each one in order
of decreasing richness of the mimetype. The current order is
- application/vnd.jupyter.widget-view+json
- text/html
- text/markdown (only if `markdown-mode' is available)
- text/latex
- image/png
- image/svg+xml
- text/plain
As a special case, since Emacs is currently unable to embed a web
browser in a sufficient way, inserting a widget does not actually
insert it into the buffer. Instead the widget is displayed in a
browser.
When no valid mimetype is present in DATA, a warning is shown."
(let ((mimetypes (cl-loop
for (k d) on data by #'cddr
when
(and d (not (equal d ""))
(or (display-graphic-p)
(not (memq k jupyter-repl-graphic-mimetypes))))
collect k)))
(cond
((memq :application/vnd.jupyter.widget-view+json mimetypes)
(jupyter-widgets-display-model
jupyter-repl-current-client
(plist-get (plist-get data :application/vnd.jupyter.widget-view+json)
:model_id)))
((and (memq :text/html mimetypes)
(functionp 'libxml-parse-html-region))
(let ((html (plist-get data :text/html)))
(when (string-match-p "^<img" html)
(jupyter-repl-newline))
(jupyter-repl-insert-html html)
(jupyter-repl-newline)))
((and (memq :text/markdown mimetypes)
(require 'markdown-mode nil t))
(jupyter-repl-insert-markdown (plist-get data :text/markdown)))
((and (memq :text/latex mimetypes)
(require 'org nil t))
(jupyter-repl-insert-latex (plist-get data :text/latex))
(jupyter-repl-newline))
((memq :image/png mimetypes)
(cl-destructuring-bind (&key width height)
(plist-get metadata :image/png)
(let* ((data (base64-decode-string (plist-get data :image/png)))
(img (create-image data nil 'data :width width :height height)))
(insert-image img (propertize " " 'read-only t)))))
((and (memq :image/svg+xml mimetypes)
(image-type-available-p 'svg))
(cl-destructuring-bind (&key width height)
(plist-get metadata :image/svg+xml)
(let* ((data (plist-get data :image/svg+xml))
(img (create-image data 'svg nil :width width :height height)))
(insert-image img (propertize " " 'read-only t)))))
((memq :text/plain mimetypes)
(jupyter-repl-insert-ansi-coded-text
(plist-get data :text/plain))
(jupyter-repl-newline))
(t (warn "No supported mimetype found %s" mimetypes)))))
(defun jupyter-repl-insert-data-with-id (display-id data metadata)
"Associate DISPLAY-ID with DATA when inserting DATA.
DATA and METADATA have the same meaning as in
`jupyter-repl-insert-data'.
Currently there is no support for associating a DISPLAY-ID if
DATA is displayed as a widget."
(unless jupyter-repl-display-ids
(setq-local jupyter-repl-display-ids
(make-hash-table :test #'equal
:weakness 'value)))
(let ((beg (point))
(id (gethash display-id jupyter-repl-display-ids))
(widget-p (memq :application/vnd.jupyter.widget-view+json data)))
(or id widget-p (setq id (puthash display-id
display-id
jupyter-repl-display-ids)))
(jupyter-repl-insert-data data metadata)
(unless widget-p
(put-text-property beg (point) 'jupyter-display id))))
;;; Prompt
(defun jupyter-repl--prompt-display-value (str face)
"Return the margin display value for a prompt STR.
FACE is the `font-lock-face' to use for STR."
(list '(margin left-margin)
(propertize
(concat (make-string
(- jupyter-repl-prompt-margin-width
(length str))
? )
str)
'fontified t
'font-lock-face face)))
(defun jupyter-repl--make-prompt (str face props)
"Make a prompt overlay for the character at `point'.
STR is used as the prompt string and FACE is its
`font-lock-face'. Add PROPS as text properties to the character."
(let ((ov (make-overlay (1- (point)) (point) nil t))
(md (jupyter-repl--prompt-display-value str face)))
(overlay-put ov 'after-string (propertize " " 'display md))
(overlay-put ov 'evaporate t)
(add-text-properties (overlay-start ov) (overlay-end ov) props)))
(defun jupyter-repl-insert-prompt (&optional type)
"Insert a REPL prompt according to TYPE.
TYPE can either be `in', `out', or `continuation'. A nil TYPE is
interpreted as `in'."
(setq type (or type 'in))
(unless (memq type '(in out continuation))
(error "Prompt type can only be (`in', `out', or `continuation')"))
(jupyter-repl-without-continuation-prompts
(let ((inhibit-read-only t))
(jupyter-repl-newline)
(overlay-recenter (point))
(cond
((eq type 'in)
(let ((count (oref jupyter-repl-current-client execution-count)))
(jupyter-repl--make-prompt
(format "In [%d] " count) 'jupyter-repl-input-prompt
`(jupyter-cell (beginning ,count))))
;; Prevent prompt overlay from inheriting text properties of code at the
;; beginning of a cell.
;;
;; rear-nonsticky is to prevent code inserted after this character to
;; inherit any of its text properties.
;;
;; front-sticky is to prevent `point' from being trapped between the
;; newline of the prompt overlay and this invisible character.
;;
;; field is so that text motions will not move past this invisible
;; character.
(jupyter-repl-insert
:properties '(invisible t rear-nonsticky t front-sticky t) " "))
((eq type 'out)
;; Output is normally inserted by first going to the end of the output
;; for the request. The end of the ouput for a request is at the
;; beginning of the next cell after the request which is why we get the
;; cell count of the previous cell
(let ((count (save-excursion
(jupyter-repl-previous-cell)
(jupyter-repl-cell-count))))
(jupyter-repl--make-prompt
(format "Out [%d] " count) 'jupyter-repl-output-prompt
`(jupyter-cell (out ,count))))
;; See the note above about the invisible character for input prompts
(jupyter-repl-insert
:properties '(invisible t front-sticky t) " "))
((eq type 'continuation)
(jupyter-repl--make-prompt
" " 'jupyter-repl-input-prompt
`(read-only nil rear-nonsticky t))
;; Prevent prompt overlay from inheriting text properities of cell code.
;; See the note for input prompts.
(jupyter-repl-insert
:read-only nil
:properties '(invisible t rear-nonsticky t front-sticky t) " "))))))
(defun jupyter-repl-cell-update-prompt (str)
"Update the current cell's input prompt.
STR is the replacement prompt string."
(let ((ov (car (overlays-at (jupyter-repl-cell-beginning-position)))))
(when ov
(overlay-put ov 'after-string
(propertize
" " 'display (jupyter-repl--prompt-display-value
str 'jupyter-repl-input-prompt))))))
(defun jupyter-repl-cell-mark-busy ()
"Mark the current cell as busy."
(jupyter-repl-cell-update-prompt "In [*] "))
(defun jupyter-repl-cell-unmark-busy ()
"Un-mark the current cell as busy."
(jupyter-repl-cell-update-prompt
(format "In [%d] " (jupyter-repl-cell-count))))
(defun jupyter-repl-cell-count ()
"Return the cell count of the cell at `point'."
(let ((pos (if (jupyter-repl-cell-beginning-p) (point)
(save-excursion
(jupyter-repl-previous-cell)
(point)))))
(nth 1 (get-text-property pos 'jupyter-cell))))
(defun jupyter-repl-cell-request ()
"Return the `jupyter-request' of the current cell."
(get-text-property (jupyter-repl-cell-beginning-position) 'jupyter-request))
;;; Cell motions
(defun jupyter-repl-cell-beginning-position ()
"Return the cell beginning position of the current cell.
If `point' is already at the beginning of the current cell,
return `point'.
If the end of a cell is found before the beginning of one, i.e.
when `point' is somewhere inside the output of a cell, raise an
error.
If the beginning of the buffer is found before the beginning of a
cell, raise a `beginning-of-buffer' error."
(let ((pos (point)))
(while (not (jupyter-repl-cell-beginning-p pos))
(setq pos (previous-single-property-change pos 'jupyter-cell))
(if pos (when (jupyter-repl-cell-end-p pos)
(error "Found end of previous cell"))
(if (jupyter-repl-cell-beginning-p (point-min))
(setq pos (point-min))
(signal 'beginning-of-buffer nil))))
pos))
(defun jupyter-repl-cell-end-position ()
"Return the cell ending position of the current cell.
This is similar to `jupyter-repl-cell-beginning-position' except
the position at the end of the current cell is returned and an
error is raised if the beginning of a cell is found before an
end.
Note: If the current cell is the last cell in the buffer,
`point-max' is considered the end of the cell."
(let ((pos (point)))
(catch 'unfinalized
(while (not (jupyter-repl-cell-end-p pos))
(setq pos (next-single-property-change pos 'jupyter-cell))
(if pos (when (jupyter-repl-cell-beginning-p pos)
(error "Found beginning of next cell"))
;; Any unfinalized cell must be at the end of the buffer.
(throw 'unfinalized (point-max))))
pos)))
(defun jupyter-repl-cell-code-beginning-position ()
"Return the beginning of the current cell's code.
The code beginning position is
`jupyter-repl-cell-beginning-position' + 2
There is an extra invisible character after the prompt."
(+ (jupyter-repl-cell-beginning-position) 2))
(defun jupyter-repl-cell-code-end-position ()
"Return the end of the current cell's code.
The code ending position is
`jupyter-repl-cell-end-position' - 1
In the case of the last cell in the REPL buffer, i.e. an
unfinalized cell, the code ending position is `point-max'."
(let ((pos (jupyter-repl-cell-end-position)))
(if (= pos (point-max)) (point-max)
(1- pos))))
(defun jupyter-repl-next-cell (&optional N)
"Go to the beginning of the next cell.
Move N times where N defaults to 1. Return the count of cells
left to move."
(or N (setq N 1))
(catch 'done
(while (> N 0)
(let ((pos (next-single-property-change (point) 'jupyter-cell)))
(while (and pos (not (jupyter-repl-cell-beginning-p pos)))
(setq pos (next-single-property-change pos 'jupyter-cell)))
(unless (when pos (goto-char pos) (setq N (1- N)))
(goto-char (point-max))
(throw 'done t)))))
N)
(defun jupyter-repl-previous-cell (&optional N)
"Go to the beginning of the previous cell.
Move N times where N defaults to 1. Return the count of cells
left to move.
Note, if `point' is not at the beginning of the current cell, the
first move is to the beginning of the current cell."
(or N (setq N 1))
(catch 'done
(let ((starting-pos (point)))
(while (> N 0)
(let ((pos (previous-single-property-change (point) 'jupyter-cell)))
(while (and pos (not (jupyter-repl-cell-beginning-p pos)))
(setq pos (previous-single-property-change pos 'jupyter-cell)))
(unless (when pos (goto-char pos) (setq N (1- N)))
(goto-char (point-min))
;; Handle edge case when the first cell is at the beginning of the
;; buffer. This happens, for example, when erasing the buffer.
(when (and (/= (point) starting-pos)
(jupyter-repl-cell-beginning-p (point)))
(setq N (1- N)))
(throw 'done t))))))
N)
(defun jupyter-repl-goto-cell (req)
"Go to the cell beginning position of REQ.
REQ should be a `jupyter-request' that corresponds to one of the
`jupyter-send-execute-request's created by a cell in the
`current-buffer'. Note that the `current-buffer' is assumed to be
a Jupyter REPL buffer."
(goto-char (point-max))
(unless (catch 'done
(while (= (jupyter-repl-previous-cell) 0)
(when (eq (jupyter-repl-cell-request) req)
(throw 'done t))))
(error "Cell for request not found")))
(defun jupyter-repl-forward-cell (&optional arg)
"Go to the code beginning of the cell after the current one.
ARG is the number of cells to move and defaults to 1."
(interactive "^p")
(or arg (setq arg 1))
(jupyter-repl-next-cell arg)
(goto-char (jupyter-repl-cell-code-beginning-position)))
(defun jupyter-repl-backward-cell (&optional arg)
"Go to the code beginning of the cell before the current one.
ARG is the number of cells to move and defaults to 1."
(interactive "^p")
(or arg (setq arg 1))
;; Ignore the case when `point' is in the output of a cell, in this case
;; `jupyter-repl-previous-cell' will go to the previous cell.
(ignore-errors (goto-char (jupyter-repl-cell-beginning-position)))
(jupyter-repl-previous-cell arg)
(goto-char (jupyter-repl-cell-code-beginning-position)))
;;; Predicates
(defun jupyter-repl-cell-beginning-p (&optional pos)
"Is POS the beginning of a cell?
POS defaults to `point'."
(setq pos (or pos (point)))
(eq (nth 0 (get-text-property pos 'jupyter-cell)) 'beginning))
(defun jupyter-repl-cell-end-p (&optional pos)
"Is POS the end of a cell?
POS defaults to `point'."
(setq pos (or pos (point)))
(or (= pos (point-max))
(eq (nth 0 (get-text-property pos 'jupyter-cell)) 'end)))
(defun jupyter-repl-multiline-p (text)
"Is TEXT a multi-line string?"
(string-match-p "\n" text))
(defun jupyter-repl-cell-line-p ()
"Is the current line a cell input line?"
(let ((pos (point)))
(ignore-errors
(save-excursion
(unless (= pos (jupyter-repl-cell-beginning-position))
(jupyter-repl-previous-cell))
(<= (jupyter-repl-cell-code-beginning-position)
pos
(jupyter-repl-cell-code-end-position))))))
(defun jupyter-repl-cell-finalized-p ()
"Has the current cell been finalized?"
(jupyter-repl-cell-end-p (jupyter-repl-cell-end-position)))
(defun jupyter-repl-client-has-manager-p ()
"Does the `jupyter-repl-current-client' have a `jupyter-kernel-manager'?"
(and jupyter-repl-current-client
(oref jupyter-repl-current-client manager)))
(defun jupyter-repl-connected-p ()
"Is the `jupyter-repl-current-client' connected to its kernel?"
(when jupyter-repl-current-client
(or (and (jupyter-repl-client-has-manager-p)
;; Check if the kernel is local
(jupyter-kernel-alive-p
(oref jupyter-repl-current-client manager)))
(let ((hb (oref jupyter-repl-current-client hb-channel)))
(and (jupyter-channel-alive-p hb)
(jupyter-hb-beating-p hb))))))
;;; Modifying cell code, truncating REPL buffer
(defun jupyter-repl-cell-code ()
"Return the code of the current cell."
(if (= (point-min) (point-max)) ""
(let (lines pos)
(save-excursion
(goto-char (jupyter-repl-cell-code-beginning-position))
(push (buffer-substring-no-properties (point) (point-at-eol))
lines)
(while (and (= (forward-line 1) 0)
(/= (point) (point-max))
(jupyter-repl-cell-line-p))
(setq pos (next-single-property-change
(point) 'invisible nil (point-at-eol)))
(when pos (goto-char pos))
(push (buffer-substring-no-properties (point) (point-at-eol))
lines))
(mapconcat #'identity (nreverse lines) "\n")))))
(defun jupyter-repl-cell-code-position ()
"Return the relative position of `point' with respect to the cell code."
(unless (jupyter-repl-cell-line-p)
(error "Not in code of cell"))
(1+ (- (point) (jupyter-repl-cell-code-beginning-position))))
(defun jupyter-repl-finalize-cell (req)
"Finalize the current cell.
REQ is the `jupyter-request' to associate with the current cell."
(let ((beg (jupyter-repl-cell-beginning-position))
(count (jupyter-repl-cell-count)))
(goto-char (point-max))
(jupyter-repl-newline)
(put-text-property (1- (point)) (point) 'jupyter-cell `(end ,count))
(put-text-property beg (1+ beg) 'jupyter-request req)
;; Remove this property so that text can't be inserted at the start of the
;; cell or after any continuation prompts. See
;; `jupyter-repl-insert-prompt'.
(remove-text-properties beg (point) '(rear-nonsticky))
;; font-lock-multiline to avoid improper syntactic elements from
;; spilling over to the rest of the buffer.
(add-text-properties beg (point) '(read-only t font-lock-multiline t))))
(defun jupyter-repl-replace-cell-code (new-code)
"Replace the current cell code with NEW-CODE."
(goto-char (jupyter-repl-cell-code-beginning-position))
(delete-region (point) (jupyter-repl-cell-code-end-position))
(jupyter-repl-insert :inherit t :read-only nil new-code))
(defun jupyter-repl-truncate-buffer ()
"Truncate the `current-buffer' based on `jupyter-repl-maximum-size'.
The `current-buffer' is assumed to be a Jupyter REPL buffer. If
the `current-buffer' is larger than `jupyter-repl-maximum-size'
lines, truncate it to something less than
`jupyter-repl-maximum-size' lines."
(save-excursion
(when (= (forward-line (- jupyter-repl-maximum-size)) 0)
(jupyter-repl-next-cell)
(delete-region (point-min) (point)))))
;;; Handlers
(defun jupyter-repl-history-add-input (code)
"Add CODE as the newest element in the REPL history."
;; Ensure the newest element is actually the newest element and not the most
;; recently navigated history element.
(while (not (eq (ring-ref jupyter-repl-history -1) 'jupyter-repl-history))
(ring-insert jupyter-repl-history (ring-remove jupyter-repl-history)))
;; Remove the second to last element when the ring is full to preserve the
;; sentinel.
(when (eq (ring-length jupyter-repl-history)
(ring-size jupyter-repl-history))
(ring-remove jupyter-repl-history -2))
(ring-remove+insert+extend jupyter-repl-history code))
(cl-defmethod jupyter-send-execute-request ((client jupyter-repl-client)
&key code
(silent nil)
(store-history t)
(user-expressions nil)
(allow-stdin t)
(stop-on-error nil))
(if code (cl-call-next-method)
(with-jupyter-repl-buffer client
(jupyter-repl-truncate-buffer)
(setq code (string-trim (jupyter-repl-cell-code)))
;; Handle empty code cells as just an update of the prompt number
(if (= (length code) 0)
(setq silent t)
;; Needed by the prompt insertion below
(oset client execution-count (1+ (oref client execution-count)))
(jupyter-repl-history-add-input code))
(let ((req (cl-call-next-method
client :code code :silent silent :store-history store-history
:user-expressions user-expressions :allow-stdin allow-stdin
:stop-on-error stop-on-error)))
(jupyter-repl-without-continuation-prompts
(jupyter-repl-cell-mark-busy)
(jupyter-repl-finalize-cell req)
(jupyter-repl-insert-prompt 'in))
req))))
(defun jupyter-repl--handle-payload (payload)
"Do the client actions in PAYLOAD."
(cl-loop
for pl across payload
do (pcase (plist-get pl :source)
("page"
(let ((text (plist-get (plist-get pl :data) :text/plain))
(line (or (plist-get pl :start) 0)))
(with-jupyter-repl-doc-buffer "pager"
(jupyter-repl-insert-ansi-coded-text text)
(goto-char (point-min))
(forward-line line)
(display-buffer (current-buffer)))))
((or "edit" "edit_magic")
(with-current-buffer (find-file-other-window
(plist-get pl :filename))
(goto-char (point-min))
(forward-line (plist-get pl :line_number))
(set-window-start (selected-window) (point))))
("set_next_input"
(goto-char (point-max))
(jupyter-repl-previous-cell)
(jupyter-repl-replace-cell-code (plist-get pl :text))))))
(cl-defmethod jupyter-handle-execute-reply ((client jupyter-repl-client)
req
_status
execution-count
_user-expressions
payload)
(oset client execution-count (1+ execution-count))
(with-jupyter-repl-buffer client
(save-excursion
(jupyter-repl-goto-cell req)
(jupyter-repl-cell-unmark-busy))
(when payload
(jupyter-repl--handle-payload payload))))
(cl-defmethod jupyter-handle-execute-input ((client jupyter-repl-client)
_req
_code
execution-count)
(oset client execution-count (1+ execution-count)))
(cl-defmethod jupyter-handle-execute-result ((client jupyter-repl-client)
req
_execution-count
data
metadata)
;; Only handle our results
(when req
(jupyter-repl-do-at-request client req
(jupyter-repl-insert-prompt 'out)
(jupyter-repl-insert-data data metadata))))
(defun jupyter-repl-next-display-with-id (id)
"Go to the start of the next display matching ID.
Return non-nil if successful. If no display with ID is found,
return nil without moving `point'."
(let ((pos (next-single-property-change (point) 'jupyter-display)))
(while (and pos (not (eq (get-text-property pos 'jupyter-display) id)))
(setq pos (next-single-property-change pos 'jupyter-display)))
(and pos (goto-char pos))))
(defun jupyter-repl-update-display (id data metadata)
"Update the display with ID using DATA.
DATA and METADATA have the same meaning as in a `:display-data'
message."
;; Updating a display involves finding and clearing the data that is
;; currently associated with the ID and inserting DATA at the same location.
;; If multiple locations have the same display ID, all of them are updated.
;; Raise an error if no display with ID could be found.
(save-excursion
(goto-char (point-min))
(let (str)
(while (jupyter-repl-next-display-with-id id)
(or str (setq str (with-temp-buffer
(jupyter-repl-insert-data data metadata)
(put-text-property
(point-min) (point-max) 'jupyter-display id)
(buffer-string))))
(delete-region (point) (next-single-property-change
(point) 'jupyter-display))
(let ((beg (point)) ov)
(insert str)
(setq ov (make-overlay (1+ beg) (point)))
(overlay-put ov 'face 'secondary-selection)
(run-at-time 0.3 nil (lambda () (delete-overlay ov)))))
(when (= (point) (point-min))
(error "No display matching id (%s)" id)))))
;; NOTE: Info on display_id
;; https://github.com/jupyter/jupyter_client/issues/209
(cl-defmethod jupyter-handle-display-data ((client jupyter-repl-client)
req
data
metadata
transient)
(let ((clear (prog1 (oref client wait-to-clear)
(oset client wait-to-clear nil)))
(req (if (eq (jupyter-message-parent-type
(jupyter-request-last-message req))
:comm-msg)
;; For comm messages which produce a `:display-data' message,
;; the request is assumed to be the most recently completed
;; one.
(with-jupyter-repl-buffer client
(save-excursion
(goto-char (point-max))
(jupyter-repl-previous-cell 2)
(jupyter-repl-cell-request)))
req)))
(jupyter-repl-do-at-request client req
(cl-destructuring-bind (&key display_id &allow-other-keys)
transient
(if display_id
(jupyter-repl-insert-data-with-id display_id data metadata)
(let ((inhibit-redisplay t))
(when clear
(jupyter-repl-clear-last-cell-output client)
;; Prevent slight flickering of prompt margin and text, this is
;; needed in addition to `inhibit-redisplay'. It also seems that
;; it can be placed anywhere within this let and it will prevent
;; flickering.
(sit-for 0.1 t))
(jupyter-repl-insert-data data metadata)))))))
(cl-defmethod jupyter-handle-update-display-data ((client jupyter-repl-client)
_req
data
metadata
transient)
(cl-destructuring-bind (&key display_id &allow-other-keys)
transient
(unless display_id
(error "No display ID in `:update-display-data' message"))
(with-jupyter-repl-buffer client
(let ((id (gethash display_id jupyter-repl-display-ids)))
(unless id
(error "Display ID not found (%s)" id))
(jupyter-repl-update-display id data metadata)))))
(defun jupyter-repl-clear-last-cell-output (client)
"In CLIENT's REPL buffer, clear the output of the last completed cell."
(with-jupyter-repl-buffer client
(goto-char (point-max))
(jupyter-repl-previous-cell 2)
(delete-region (1+ (jupyter-repl-cell-end-position))
(progn
(jupyter-repl-next-cell)
(point)))))
(cl-defmethod jupyter-handle-clear-output ((client jupyter-repl-client)
req
wait)
;; TODO: Take into account json-false elsewhere
(unless (oset client wait-to-clear (eq wait t))
(cond
((eq (jupyter-message-parent-type
(jupyter-request-last-message req))
:comm-msg)
(with-current-buffer (get-buffer-create "*jupyter-repl-output*")
(erase-buffer)))
(t
(jupyter-repl-clear-last-cell-output client)))))
(cl-defmethod jupyter-handle-status ((client jupyter-repl-client) _req execution-state)
(oset client execution-state execution-state))
(defvar jupyter-repl--output-marker nil)
(defun jupyter-repl-display-other-output (client stream text)
"Display output not originating from CLIENT.
STREAM is the name of a stream which will be used to select the
buffer to display TEXT."
(let* ((bname (buffer-name (oref client buffer)))
(inhibit-read-only t)
(stream-buffer
(concat (substring bname 0 (1- (length bname)))
"-" stream "*")))
(with-current-buffer (get-buffer-create stream-buffer)
(unless jupyter-repl--output-marker
(setq-local jupyter-repl--output-marker (set-marker (make-marker) (point-max))))
(goto-char jupyter-repl--output-marker)
(let ((pos (point)))
(jupyter-repl-insert-ansi-coded-text text)
(fill-region pos (point)))
(set-marker jupyter-repl--output-marker (point))
(display-buffer (current-buffer) '(display-buffer-pop-up-window
(pop-up-windows . t))))))
(cl-defmethod jupyter-handle-stream ((client jupyter-repl-client) req name text)
(if (null req)
(jupyter-repl-display-other-output client name text)
(cond
((eq (jupyter-message-parent-type
(jupyter-request-last-message req))
:comm-msg)
(with-current-buffer (get-buffer-create "*jupyter-repl-output*")
(let ((inhibit-read-only t))
(jupyter-repl-insert-ansi-coded-text text)
(display-buffer (current-buffer)))))
(t
(jupyter-repl-do-at-request client req
(jupyter-repl-insert-ansi-coded-text text))))))
(defun jupyter-repl-fix-python-traceback-spacing (ename)
"Add spacing between the first occurance of ENAME and \"Traceback\".
Do this for the current cell."
(save-excursion
(jupyter-repl-previous-cell)
(when (and (search-forward ename nil t)
(looking-at "Traceback"))
(let ((len (- fill-column
jupyter-repl-prompt-margin-width
(- (point) (line-beginning-position))
(- (line-end-position) (point)))))
(jupyter-repl-insert
(make-string (if (> len 4) len 4) ? ))))))
(cl-defmethod jupyter-handle-error ((client jupyter-repl-client)
req ename _evalue traceback)
(when req
(setq traceback (concat (mapconcat #'identity traceback "\n") "\n"))
(cond
((eq (jupyter-message-parent-type
(jupyter-request-last-message req))
:comm-msg)
(with-jupyter-repl-doc-buffer "traceback"
(jupyter-repl-insert-ansi-coded-text traceback)
(goto-char (line-beginning-position))
(pop-to-buffer (current-buffer))))
(t
(jupyter-repl-do-at-request client req
(jupyter-repl-insert-ansi-coded-text traceback)
(when (equal (jupyter-repl-language client) "python")
(jupyter-repl-fix-python-traceback-spacing ename)))))))
(cl-defmethod jupyter-handle-input-reply ((client jupyter-repl-client) req prompt _password)
(jupyter-repl-do-at-request client req
(let ((value (cl-call-next-method)))
(jupyter-repl-insert (concat prompt value))
(jupyter-repl-newline))))
(defun jupyter-repl-history--next (n)
"Helper function for `jupyter-repl-history-next'.
Rotates `jupyter-repl-history' N times in the forward direction,
towards newer history elements and returns the Nth history
element in that direction relative to the current REPL history.
If the sentinel value is found before rotating N times, return
nil."
(if (> n 0)
(if (eq (ring-ref jupyter-repl-history -1) 'jupyter-repl-history)
nil
(ring-insert jupyter-repl-history
(ring-remove jupyter-repl-history -1))
(jupyter-repl-history--next (1- n)))
(ring-ref jupyter-repl-history 0)))
(defun jupyter-repl-history-next (&optional n)
"Go to the next history element.
Navigate through the REPL history to the next (newer) history
element and insert it as the last code cell. For N positive move
forward in history that many times. If N is negative, move to
older history elements."
(interactive "p")
(or n (setq n 1))
(if (< n 0) (jupyter-repl-history-previous (- n))
(goto-char (point-max))
(let ((elem (jupyter-repl-history--next n)))
(if (and (null elem) (equal (jupyter-repl-cell-code) ""))
(error "End of history")
(if (null elem)
;; When we have reached the last history element in the forward
;; direction and the cell code is not empty, make it empty.
(jupyter-repl-replace-cell-code "")
(jupyter-repl-replace-cell-code
(ring-ref jupyter-repl-history 0)))))))
(defun jupyter-repl-history--previous (n)
"Helper function for `jupyter-repl-history-previous'.
Rotates `jupyter-repl-history' N times in the backward direction,
towards older history elements and returns the Nth history
element in that direction relative to the current REPL history.
If the sentinel value is found before rotating N times, return
nil."
(if (> n 0)
(if (eq (ring-ref jupyter-repl-history 1) 'jupyter-repl-history)
nil
(ring-insert-at-beginning
jupyter-repl-history (ring-remove jupyter-repl-history 0))
(jupyter-repl-history--previous (1- n)))
(ring-ref jupyter-repl-history 0)))
(defun jupyter-repl-history-previous (&optional n)
"Go to the previous history element.
Similar to `jupyter-repl-history-next' but for older history
elements. If N is negative in this case, move to newer history
elements."
(interactive "p")
(or n (setq n 1))
(if (< n 0) (jupyter-repl-history-next (- n))
(goto-char (point-max))
(unless (equal (jupyter-repl-cell-code)
(ring-ref jupyter-repl-history 0))
(setq n (1- n)))
(let ((elem (jupyter-repl-history--previous n)))
(if (null elem)
(error "Beginning of history")
(jupyter-repl-replace-cell-code
(ring-ref jupyter-repl-history 0))))))
(cl-defmethod jupyter-handle-history-reply ((client jupyter-repl-client) _req history)
(with-jupyter-repl-buffer client
(cl-loop for elem across history
for input-output = (aref elem 2)
do (ring-remove+insert+extend jupyter-repl-history input-output))))
(cl-defmethod jupyter-handle-is-complete-reply ((client jupyter-repl-client) _req status indent)
(with-jupyter-repl-buffer client
(pcase status
("complete"
(jupyter-send-execute-request client))
("incomplete"
(jupyter-repl-newline)
(if (= (length indent) 0) (jupyter-repl-indent-line)
(jupyter-repl-insert :read-only nil indent)))
("invalid"
;; Force an execute to produce a traceback
(jupyter-send-execute-request client))
("unknown"))))
(cl-defmethod jupyter-handle-shutdown-reply ((client jupyter-repl-client) _req restart)
(with-jupyter-repl-buffer client
(goto-char (point-max))
(add-text-properties (jupyter-repl-cell-beginning-position)
(jupyter-repl-cell-end-position)
'(read-only t))
(jupyter-repl-without-continuation-prompts
(jupyter-repl-newline)
(jupyter-repl-newline)
;; TODO: Add a slot mentioning that the kernel is shutdown so that we can
;; block sending requests or delay until it has restarted.
(jupyter-repl-insert
(propertize (concat "kernel " (if restart "restart" "shutdown"))
'font-lock-face 'warning))
(jupyter-repl-newline))))
(defun jupyter-repl-ret (&optional force)
"Send the current cell code to the kernel.
If `point' is before the last cell in the REPL buffer move to
`point-max', i.e. move to the last cell. Otherwise if `point' is
at some position within the last cell, either insert a newline or
ask the kernel to execute the cell code depending on the kernel's
response to an `:is-complete-request'.
If a prefix argument is given, FORCE the kernel to execute the
current cell code without sending an `:is-complete-request'. See
`jupyter-repl-use-builtin-is-complete' for yet another way to
execute the current cell."
(interactive "P")
(condition-case nil
(let ((cell-beginning (save-excursion
(goto-char (point-max))
(jupyter-repl-cell-beginning-position))))
(if (< (point) cell-beginning)
(goto-char (point-max))
(unless (jupyter-repl-connected-p)
(error "Kernel not alive"))
;; NOTE: kernels allow execution requests to queue up, but we prevent
;; sending a request when the kernel is busy because of the
;; is-complete request. Some kernels don't respond to this request
;; when the kernel is busy.
(unless (member (oref jupyter-repl-current-client execution-state)
'("starting" "idle"))
(jupyter-repl-sync-execution-state)
(error "Kernel busy"))
(if force (jupyter-send-execute-request jupyter-repl-current-client)
(if (not jupyter-repl-use-builtin-is-complete)
(let* ((jupyter-inhibit-handlers '(:status))
(res (jupyter-wait-until-received :is-complete-reply
(jupyter-send-is-complete-request
jupyter-repl-current-client
:code (jupyter-repl-cell-code))
jupyter-repl-maximum-is-complete-timeout)))
(unless res
(message "Kernel did not respond to is-complete-request, using built-in is-complete.
Reset `jupyter-repl-use-builtin-is-complete' to nil if this is only temporary.")
(setq jupyter-repl-use-builtin-is-complete t)
(jupyter-repl-ret force)))
(goto-char (point-max))
(let ((complete-p (equal (buffer-substring
(line-beginning-position) (point))
"")))
(jupyter-handle-is-complete-reply
jupyter-repl-current-client
nil (if complete-p "complete" "incomplete") ""))))))
(beginning-of-buffer
;; No cells in the current buffer, just insert one
(jupyter-repl-insert-prompt 'in))))
(defun jupyter-repl-indent-line ()
"Indent the line according to the language of the REPL."
(let* ((spos (jupyter-repl-cell-code-beginning-position))
(pos (jupyter-repl-cell-code-position))
(code (jupyter-repl-cell-code))
(replacement (with-jupyter-repl-lang-buffer
(insert code)
(goto-char pos)
(indent-according-to-mode)
(setq pos (point))
(buffer-string))))
;; Don't modify the buffer when unnecessary, this allows
;; `company-indent-or-complete-common' to work.
(unless (equal code replacement)
(jupyter-repl-replace-cell-code replacement)
(goto-char (+ pos spos)))))
;;; Buffer change functions
(defun jupyter-repl-insert-continuation-prompts (bound)
"Insert continuation prompts if needed, stopping at BOUND.
Return the new BOUND since inserting continuation prompts may add
more characters than were initially in the buffer."
(setq bound (set-marker (make-marker) bound))
(set-marker-insertion-type bound t)
(while (and (< (point) bound)
(search-forward "\n" bound 'noerror))
(delete-char -1)
(jupyter-repl-insert-prompt 'continuation))
(prog1 (marker-position bound)
(set-marker bound nil)))
(defun jupyter-repl-mark-as-cell-code (beg end)
"Add the field property to text between (BEG . END) if within a code cell."
;; Handle field boundary at the front of the cell code
(when (= beg (jupyter-repl-cell-code-beginning-position))
(put-text-property beg (1+ beg) 'front-sticky t))
(when (text-property-not-all beg end 'field 'cell-code)
(font-lock-fillin-text-property beg end 'field 'cell-code)))
(defun jupyter-repl-after-buffer-change (beg end len)
"Insert line continuation prompts in `jupyter-repl-mode' buffers.
BEG, END, and LEN have the same meaning as in
`after-change-functions'."
(when (eq major-mode 'jupyter-repl-mode)
(cond
;; Insertions only
((= len 0)
(goto-char beg)
(when (jupyter-repl-cell-line-p)
(setq end (jupyter-repl-insert-continuation-prompts end))
(jupyter-repl-mark-as-cell-code beg end))
(goto-char end)))))
(defun jupyter-repl-kill-buffer-query-function ()
"Ask before killing a Jupyter REPL buffer.
If the REPL buffer is killed, stop the client. If the REPL client
is connected to a kernel with a `jupyter-kernel-manager', kill
the kernel.
In addition, exit `jupyter-repl-interaction-mode' in all buffers
associated with the REPL. See `jupyter-repl-associate-buffer'."
(when (eq major-mode 'jupyter-repl-mode)
(if (not (jupyter-channels-running-p jupyter-repl-current-client)) t
(when (y-or-n-p
(format "Jupyter REPL (%s) still connected. Kill it? "
(buffer-name (current-buffer))))
;; TODO: Handle case when multiple clients are connected, i.e. do we
;; want to also delete a kernel if this is the last client connected.
;; See `eieio-instance-tracker'.
(prog1 t
(jupyter-stop-channels jupyter-repl-current-client)
(destructor jupyter-repl-current-client)
(when (jupyter-repl-client-has-manager-p)
(jupyter-shutdown-kernel (oref jupyter-repl-current-client manager))
(destructor (oref jupyter-repl-current-client manager)))
(cl-loop
with client = jupyter-repl-current-client
for buffer in (buffer-list)
do (with-current-buffer buffer
(when (eq jupyter-repl-current-client client)
(jupyter-repl-interaction-mode -1)))))))))
(defun jupyter-repl-preserve-window-margins (&optional window)
"Ensure that the margins of a REPL window are present.
This function is added as a hook to `pre-redisplay-functions' to
ensure that a REPL windows margins are present.
If WINDOW is showing a REPL buffer and the margins are not set to
`jupyter-repl-prompt-margin-width', set them to the proper
value."
;; NOTE: Sometimes the margins will disappear after the window configuration
;; changes which is why `window-configuration-change-hook' is not used.
(when (and (eq major-mode 'jupyter-repl-mode)
(let ((margins (window-margins window)))
(not (and (consp margins)
(car margins)
(= (car margins) jupyter-repl-prompt-margin-width)))))
(set-window-buffer window (current-buffer))))
;;; Completion
(defconst jupyter-completion-argument-regexp
(rx
(group "(" (zero-or-more anything) ")")
(one-or-more anything) " "
(group (one-or-more anything)) ?: (group (one-or-more digit)))
"Regular expression to match arguments and file locations.")
;;; Helpers for completion interface
(defun jupyter-completion-symbol-beginning (&optional pos)
"Return the starting position of a completion symbol.
If POS is non-nil return the position of the symbol before POS
otherwise return the position of the symbol before point."
(save-excursion
(and pos (goto-char pos))
(+ (point) (skip-syntax-backward "w_"))))
;; Adapted from `company-grab-symbol-cons'
(defun jupyter-completion-grab-symbol-cons (re &optional max-len)
"Return the current completion prefix before point.
Return either a STRING or a (STRING . t) pair. If RE matches the
beginning of the current symbol before point, return the latter.
Otherwise return the symbol before point. If no completion can be
done at point, return nil.
MAX-LEN is the maximum number of characters to search behind the
begiining of the symbol at point to look for a match of RE."
(let ((symbol (if (looking-at "\\>")
(buffer-substring
(point) (jupyter-completion-symbol-beginning))
(unless (and (char-after)
(memq (char-syntax (char-after)) '(?w ?_)))
""))))
(when symbol
(save-excursion
(forward-char (- (length symbol)))
(if (looking-back re (if max-len
(- (point) max-len)
(line-beginning-position)))
(cons symbol t)
symbol)))))
(defun jupyter-completion-floating-point-p ()
"Return non-nil if the text before `point' may be a floating point number.
If the preceeding two characters before point are \"[0-9].\"
return non-nil, otherwise return nil."
(and (= (char-before (point)) ?.)
(<= ?0 (char-before (1- (point))) ?9)))
;;; Extracting arguments from argument strings
(defun jupyter-completion--arg-extract-1 (pos)
"Helper function for `arg-extract-top'.
Extract the arguments starting at POS and narrowing to the first
SEXP."
(save-restriction
(goto-char pos)
(narrow-to-region
pos (save-excursion (forward-sexp) (point)))
(jupyter-completion--arg-extract)))
(defun jupyter-completion--arg-extract ()
"Extract arguments from an argument string.
Works for Julia and Python."
;; TODO: This only extracts out arguments between {}, () pairs but I need to
;; also consider how to extract single arguments like the T in C::T
(let (arg-info
inner-args ppss depth inner
(start (1+ (point-min)))
(get-sexp
(lambda ()
(buffer-substring-no-properties
(point) (progn (forward-sexp) (point)))))
(get-string
(lambda (start)
(string-trim
(buffer-substring-no-properties
start (1- (point)))))))
(while (re-search-forward ",\\|::" nil t)
(setq ppss (syntax-ppss)
depth (nth 0 ppss)
inner (nth 1 ppss))
(cl-case (char-before)
(?:
(if (eq (char-after) ?{)
(push (jupyter-completion--arg-extract-1 (point)) inner-args)
(push (list (list (funcall get-sexp))) inner-args)))
(?,
(if (/= depth 1)
(push (jupyter-completion--arg-extract-1 inner) inner-args)
(push (cons (funcall get-string start) (pop inner-args))
arg-info)
(setq start (1+ (point)))))))
(goto-char (point-max))
(push (cons (funcall get-string start) (pop inner-args)) arg-info)
(nreverse arg-info)))
(defun jupyter-completion--make-arg-snippet (args)
"Construct a snippet from ARGS."
(cl-loop
with i = 1
for top-args in args
;; TODO: Handle nested arguments
for (arg . inner-args) = top-args
collect (format "${%d:%s}" i arg) into constructs
and do (setq i (1+ i))
finally return
(concat "(" (mapconcat #'identity constructs ", ") ")")))
;;; Getting the completion context
(defun jupyter-code-context-at-point (type)
"Return a cons cell, (CODE . POS), for the context around `point'.
CODE is the required context for TYPE (either `inspect' or
`completion') and POS is the relative position of `point' within
CODE.
The returned CODE depends on the `major-mode' of the
`current-buffer'. If the `major-mode' is `jupyter-repl-mode',
CODE is the contents of the current cell. Otherwise its either
the line up to `point' if TYPE is `completion' or the entire line
if TYPE is `inspect'."
(unless (memq type '(completion inspect))
(error "Type not `complete' or `inspect' (%s)" type))
(let (code pos)
(cl-case type
(inspect
(setq code
;; TODO: This still needs work
(save-excursion
;; Ignore the invisible characters of a prompt
(when (and (eq major-mode 'jupyter-repl-mode)
(field-at-pos (point)))
(goto-char (1+ (field-end))))
(buffer-substring
(line-beginning-position)
(line-end-position)))
;; NOTE: The +1 is because normally, when inspecting code, `point'
;; is on a character of the symbol being inspected, this is in
;; contrast to completing code where `point' is after the last
;; character of the prefix. This fixes an edge case where `point'
;; is at the first character of a symbol.
pos (1+ (- (point) (line-beginning-position)))))
(complete
(if (eq major-mode 'jupyter-repl-mode)
(setq code (jupyter-repl-cell-code)
pos (1- (jupyter-repl-cell-code-position)))
(setq code (buffer-substring (line-beginning-position) (point))
pos (- (point) (line-beginning-position))))))
(list code pos)))
(defun jupyter-completion-prefix ()
"Return the prefix for the current completion context.
Note that the prefix returned is not the content sent to the
kernel, but the symbol at `point'. See
`jupyter-code-context-at-point' for what is actually sent."
(when jupyter-repl-current-client
(let ((lang (jupyter-repl-language jupyter-repl-current-client))
(lang-mode (jupyter-repl-language-mode jupyter-repl-current-client)))
(and (memq major-mode `(,lang-mode jupyter-repl-mode))
;; No completion in finalized cells
(not (get-text-property (point) 'read-only))
(cond
;; Completing argument lists
((and (eq (char-syntax (char-before)) ?\()
(or (not (char-after))
(not (memq (char-syntax (char-after)) '(?w ?_)))))
(buffer-substring
(jupyter-completion-symbol-beginning (1- (point)))
(point)))
(t
(let* ((s (jupyter-completion-grab-symbol-cons "\\." 1)))
(unless (and (consp s) (string= (car s) "")
(jupyter-completion-floating-point-p))
s))))))))
(defun jupyter-completion-construct-candidates (matches metadata)
"Construct candidates for completion.
MATCHES are the completion matches returned by the kernel,
METADATA is any extra data associated with MATCHES that was
supplied by the kernel."
(let* ((matches (append matches nil))
(tail matches)
(types (append (plist-get metadata :_jupyter_types_experimental) nil))
(buf))
(save-current-buffer
(unwind-protect
(while tail
(when (string-match jupyter-completion-argument-regexp (car tail))
(let* ((str (car tail))
(args-str (match-string 1 str))
(end (match-end 1))
(path (match-string 2 str))
(line (string-to-number (match-string 3 str)))
(snippet (progn
(unless buf
(setq buf (generate-new-buffer " *temp*"))
(set-buffer buf))
(insert args-str)
(goto-char (point-min))
(prog1 (jupyter-completion--make-arg-snippet
(jupyter-completion--arg-extract))
(erase-buffer)))))
(setcar tail (substring (car tail) 0 end))
(put-text-property 0 1 'snippet snippet (car tail))
(put-text-property 0 1 'location (cons path line) (car tail))
(put-text-property 0 1 'docsig (car tail) (car tail))))
(setq tail (cdr tail)))
(when buf (kill-buffer buf))))
;; When a type is supplied add it as an annotation
(when types
(let ((max-len (apply #'max (mapcar #'length matches))))
(cl-mapc
(lambda (match meta)
(let* ((prefix (make-string (1+ (- max-len (length match))) ? ))
(annot (concat prefix (plist-get meta :type))))
(put-text-property 0 1 'annot annot match)))
matches types)))
matches))
;;; Completion at point interface
(defvar jupyter-completion-last-prefix nil
"The last prefix used to fetch candidates.")
(defvar jupyter-completion-cache nil
"The cache for completion candidates.
Can either be a Jupyter message plist or a list of candidates")
(defun jupyter-completion-prefetch (fun)
"Get completions for the current completion context.
Run FUN when the completions are available."
(cl-destructuring-bind (code pos)
(jupyter-code-context-at-point 'completion)
(let ((req (let ((jupyter-inhibit-handlers t))
(jupyter-send-complete-request
jupyter-repl-current-client
:code code :pos pos))))
(prog1 req
(jupyter-add-callback req :complete-reply fun)))))
(defvar jupyter-completion--company-timer nil)
(defun jupyter-completion-at-point ()
"Function to add to `completion-at-point-functions'."
(let ((prefix (jupyter-completion-prefix)) req)
(when jupyter-completion--company-timer
(cancel-timer jupyter-completion--company-timer))
(when prefix
(when (consp prefix)
(setq prefix (car prefix))
(when (and (bound-and-true-p company-mode)
(not company-candidates)
(< (length prefix) company-minimum-prefix-length))
;; Trigger completion similar to `company' when
;; `jupyter-completion-prefix' returns a cons cell.
(setq jupyter-completion--company-timer
;; NOTE: When we reach here `company-idle-delay' is `now' since
;; we are already inside a company completion so we can't use
;; it, just use a sensible time value instead.
(run-with-timer 0.1 nil 'company-manual-begin))))
;; Prefetch candidates
(when (or (not jupyter-completion-last-prefix)
(not jupyter-completion-cache)
(and
;; Only when there is no whitespace before point since some
;; kernel's would give a list of all completions on every
;; space.
(not (eq (char-syntax (char-before)) ? ))
(or
;; This case happens when completing things like foo.|
(string= jupyter-completion-last-prefix "")
;; The obvious condition...
(not (string-prefix-p jupyter-completion-last-prefix prefix))))
;; Invalidate the cache when completing argument lists
(and (not (string= prefix ""))
(eq (aref prefix (1- (length prefix))) ?\()))
(setq
jupyter-completion-last-prefix prefix
req (jupyter-completion-prefetch
(lambda (msg) (setq jupyter-completion-cache
(cons 'fetched msg))))))
(list
(- (point) (length prefix)) (point)
(completion-table-dynamic
(lambda (_)
(when (and req (not (jupyter-request-idle-received-p req))
(not (eq (jupyter-message-type
(jupyter-request-last-message req))
:complete-reply)))
(jupyter-wait-until-received :complete-reply req))
(when (eq (car jupyter-completion-cache) 'fetched)
(cl-destructuring-bind (&key status
matches metadata
&allow-other-keys)
(jupyter-message-content (cdr jupyter-completion-cache))
(setq jupyter-completion-cache
(when (equal status "ok")
(jupyter-completion-construct-candidates
matches metadata)))))
jupyter-completion-cache))
:exit-function
#'jupyter-completion--post-completion
:company-location
(lambda (arg) (get-text-property 0 'location arg))
:annotation-function
(lambda (arg) (get-text-property 0 'annot arg))
:company-docsig
(lambda (arg) (get-text-property 0 'docsig arg))
:company-doc-buffer
#'jupyter-completion--company-doc-buffer))))
(defun jupyter-completion--company-doc-buffer (arg)
(let* ((inhibit-read-only t)
(buf (jupyter-repl--inspect
arg (length arg) nil (company-doc-buffer)
company-async-timeout)))
(prog1 buf
(when buf
(with-current-buffer buf
(remove-text-properties
(point-min) (point-max) '(read-only)))))))
(defun jupyter-completion--post-completion (arg status)
"If ARG is a completion with a snippet, expand the snippet.
Do this only if STATUS is sole or finished."
(when (and (memq status '(sole finished))
(get-text-property 0 'snippet arg)
(bound-and-true-p yas-minor-mode))
(yas-expand-snippet
(get-text-property 0 'snippet arg)
(save-excursion
(forward-sexp -1)
(point))
(point))))
;;; Inspection
(defun jupyter-repl--inspect (code pos &optional detail buffer timeout)
"Send an inspect request to a Jupyter kernel.
POS is the position of `point' relative to the inspected CODE.
DETAIL is the detail level to use for the request and defaults to
0.
If BUFFER is provided, the inspection text returned from the
kernel is inserted into BUFFER and BUFFER is returned. Otherwise,
when BUFFER is nil, the formated inspection string is returned.
It the kernel doesn't respond within TIMEOUT seconds, return nil."
(let* ((jupyter-inhibit-handlers '(:status))
(msg (jupyter-wait-until-received :inspect-reply
(jupyter-send-inspect-request jupyter-repl-current-client
:code code :pos pos :detail detail)
timeout)))
(when msg
(cl-destructuring-bind (&key status found data metadata &allow-other-keys)
(jupyter-message-content msg)
(when (and (equal status "ok") found)
(with-current-buffer (or buffer (generate-new-buffer " *temp*"))
(unwind-protect
(progn
(jupyter-repl-insert-data data metadata)
(goto-char (point-min))
(or buffer (buffer-string)))
(unless buffer
(kill-buffer (current-buffer))))))))))
(defun jupyter-repl-inspect-at-point ()
"Inspect the code at point.
Send an inspect request to the `jupyter-repl-current-client' of
the `current-buffer' and display the results in a buffer."
(interactive)
(cl-destructuring-bind (code pos)
(jupyter-code-context-at-point 'inspect)
(let ((buf (current-buffer)))
(with-jupyter-repl-doc-buffer "inspect"
;; Set this in the inspect buffer so that
;; `jupyter-repl-markdown-follow-link-at-point' works in the inspect
;; buffer as well.
(setq-local jupyter-repl-current-client
(buffer-local-value 'jupyter-repl-current-client buf))
;; FIXME: Better way of inserting documentation into a buffer.
;; Currently the way text is inserted is by inserting in a temp
;; buffer and returning the string, but in cases where overlays may
;; be inserted in the buffer (markdown), this fails. A better way
;; would be to supply the buffer in which to insert text like what is
;; done here, but how to make it more general for all insertion
;; types?
(if (not (jupyter-repl--inspect code pos nil (current-buffer)))
(message "Inspect timed out")
;; TODO: Customizable action
(display-buffer (current-buffer))
(set-window-start (get-buffer-window) (point-min)))))))
;;; Evaluation
(defun jupyter-repl-eval-string (str &optional silently)
"Evaluate STR with the `jupyter-repl-current-client's REPL.
Replaces the contents of the last cell in the REPL buffer with
STR before evaluating.
If the result of evaluation is more than 10 lines long, a buffer
displaying the results is shown. For results less than 10 lines
long, the result is displayed in the minibuffer.
If a prefix argument is given, SILENTLY evaluate STR without any
modification to the REPL buffer. Only the results of evaluation
are displayed."
(interactive (list (read-string "Jupyter Eval: ") current-prefix-arg))
(unless (buffer-local-value
'jupyter-repl-current-client (current-buffer))
(user-error "No `jupyter-repl-current-client' set, see `jupyter-repl-associate-buffer'"))
(with-jupyter-repl-buffer jupyter-repl-current-client
(goto-char (point-max))
(unless (= (save-excursion (jupyter-repl-previous-cell)) 0)
(jupyter-repl-insert-prompt 'in))
(setq str (if silently (string-trim str)
(prog1 nil
(jupyter-repl-replace-cell-code str))))
(let* ((jupyter-inhibit-handlers (or (and silently t) '(:execute-result)))
(req (jupyter-send-execute-request jupyter-repl-current-client
:code str)))
(jupyter-add-callback req
:execute-reply (lambda (msg)
(cl-destructuring-bind (&key status ename evalue
&allow-other-keys)
(jupyter-message-content msg)
(unless (equal status "ok")
(message "jupyter (%s): %s" ename
(ansi-color-apply evalue)))))
:execute-result
(lambda (msg)
(let ((res (jupyter-message-data msg :text/plain))
(inhibit-read-only t))
;; Prioritize the text representation
(if res
(if (and (jupyter-repl-multiline-p res)
(cl-loop
with nlines = 0
for c across res when (eq c ?\n) do (cl-incf nlines)
thereis (> nlines 10)))
(with-current-buffer
(get-buffer-create "*jupyter-repl-result*")
(erase-buffer)
(insert res)
(goto-char (point-min))
(display-buffer (current-buffer)))
(if (equal res "") (message "jupyter: eval done")
(message res)))
(with-current-buffer
(get-buffer-create "*jupyter-repl-result*")
(erase-buffer)
(jupyter-repl-insert-data
(jupyter-message-get msg :data)
(jupyter-message-get msg :metadata))
(goto-char (point-min))
(switch-to-buffer-other-window (current-buffer)))))))
req)))
(defun jupyter-repl-eval-file (file)
"Send the contents of FILE using `jupyter-repl-current-client'."
(interactive
(list (read-file-name "File name: " nil nil nil
(file-name-nondirectory
(or (buffer-file-name) "")))))
(message "Evaluating %s..." file)
(setq file (expand-file-name file))
(if (file-exists-p file)
(let* ((buf (find-buffer-visiting file))
(killp (null buf)))
(when (null buf)
(setq buf (delay-mode-hooks (find-file-noselect file))))
(with-current-buffer buf
(jupyter-repl-eval-string (buffer-string) 'silently))
(when killp
(kill-buffer)))
(error "Not a file (%s)" file)))
(defun jupyter-repl-eval-region (beg end &optional silently)
"Evaluate a region with the `jupyter-repl-current-client'.
BEG and END are the beginning and end of the region to evaluate.
SILENTLY has the same meaning as in `jupyter-repl-eval-string'."
(interactive "rP")
(jupyter-repl-eval-string
(buffer-substring-no-properties beg end) silently))
(defun jupyter-repl-eval-line-or-region ()
"Evaluate the current line or region with the `jupyter-repl-current-client'.
If the current region is active send the current region using
`jupyter-repl-eval-region', otherwise send the current line."
(interactive)
(if (use-region-p)
(jupyter-repl-eval-region (region-beginning) (region-end))
(jupyter-repl-eval-region (line-beginning-position) (line-end-position))))
;;; Kernel management
(defun jupyter-repl-on-kernel-restart (client msg)
"Update the REPL buffer after CLIENT restarts.
If MSG is a startup message, insert the banner of the kernel,
synchronize the execution state, and insert a new input prompt."
(prog1 nil
(with-jupyter-repl-buffer client
(when (jupyter-message-status-starting-p msg)
;; FIXME: Don't assume `jupyter-include-other-output' was previously nil
(jupyter-set jupyter-repl-current-client 'jupyter-include-other-output nil)
(jupyter-repl-without-continuation-prompts
(goto-char (point-max))
(jupyter-repl-previous-cell)
(unless (jupyter-repl-cell-finalized-p)
(jupyter-repl-finalize-cell nil)
(jupyter-repl-newline)
(jupyter-repl-insert-banner
(plist-get (oref client kernel-info) :banner))
(jupyter-repl-sync-execution-state)
(jupyter-repl-insert-prompt 'in)))))))
(defun jupyter-repl-interrupt-kernel ()
"Interrupt the kernel if possible.
A kernel can be interrupted if it was started using a
`jupyter-kernel-manager'. See `jupyter-start-new-kernel'."
(interactive)
(if (not (jupyter-repl-client-has-manager-p))
(user-error "Cannot interrupt non-subprocess kernels")
(message "Interrupting kernel")
(jupyter-interrupt-kernel
(oref jupyter-repl-current-client manager))))
;; TODO: Make timeouts configurable
(defun jupyter-repl-restart-kernel (&optional shutdown)
"Restart the kernel.
With a prefix argument, SHUTDOWN the kernel completely instead."
(interactive "P")
(unless shutdown
;; This may have been set to t due to a non-responsive kernel so make sure
;; that we try again when restarting.
(setq-local jupyter-repl-use-builtin-is-complete nil)
;; When restarting, the startup message is not associated with any request
;; so ensure that we are able to capture it.
(jupyter-set jupyter-repl-current-client 'jupyter-include-other-output t))
(if (jupyter-repl-client-has-manager-p)
(let ((manager (oref jupyter-repl-current-client manager)))
(cond
((jupyter-kernel-alive-p manager)
(message "%s kernel..." (if shutdown "Shutting down"
"Restarting"))
(jupyter-shutdown-kernel manager (not shutdown)))
(t
(message "Starting dead kernel...")
(jupyter-start-kernel manager))))
(unless (jupyter-wait-until-received :shutdown-reply
(jupyter-send-shutdown-request jupyter-repl-current-client
:restart (not shutdown)))
(jupyter-set jupyter-repl-current-client 'jupyter-include-other-output nil)
(message "Kernel did not respond to shutdown request"))))
(defun jupyter-repl-display-kernel-buffer ()
"Display the kernel processes stdout."
(interactive)
(if (jupyter-repl-client-has-manager-p)
(let ((manager (oref jupyter-repl-current-client manager)))
(display-buffer (process-buffer (oref manager kernel))))
(user-error "Kernel not a subprocess")))
;;; Isearch
;; Adapted from isearch in `comint', see `comint-history-isearch-search' for
;; details
(defun jupyter-repl-isearch-setup ()
"Setup Isearch to search through the input history."
(setq-local isearch-search-fun-function
#'jupyter-repl-history-isearch-search)
(setq-local isearch-wrap-function
#'jupyter-repl-history-isearch-wrap)
(setq-local isearch-push-state-function
#'jupyter-repl-history-isearch-push-state))
;; Adapted from `comint-history-isearch-search'
(defun jupyter-repl-history-isearch-search ()
"Return a search function to search through a REPL's input history."
(lambda (string bound noerror)
(let ((search-fun (isearch-search-fun-default)) found)
(setq isearch-lazy-highlight-start-limit
(jupyter-repl-cell-beginning-position))
(or
;; 1. First try searching in the initial cell text
(funcall search-fun string
(or bound
(unless isearch-forward
(jupyter-repl-cell-code-beginning-position)))
noerror)
;; 2. If the above search fails, start putting next/prev history
;; elements in the cell successively, and search the string in them. Do
;; this only when bound is nil (i.e. not while lazy-highlighting search
;; strings in the current cell text).
(unless bound
(condition-case err
(progn
(while (not found)
(cond (isearch-forward
;; `jupyter-repl-history-next' clears the cell if the
;; last element is the sentinel, prevent that.
(if (eq (ring-ref jupyter-repl-history -1)
'jupyter-repl-history)
(error "End of history")
(jupyter-repl-history-next))
(goto-char (jupyter-repl-cell-code-beginning-position)))
(t
(jupyter-repl-history-previous)
(goto-char (point-max))))
;; After putting the next/prev history element, search the
;; string in them again, until an error is thrown at the
;; beginning/end of history.
(setq found (funcall search-fun string
(unless isearch-forward
(jupyter-repl-cell-code-beginning-position))
'noerror)))
;; Return point of the new search result
(point))
(error
(unless noerror
(signal (car err) (cdr err))))))))))
(defun jupyter-repl-history-isearch-wrap ()
"Wrap the input history search when search fails.
Go to the oldest history element for a forward search or to the
newest history element for a backward search."
(if isearch-forward
(jupyter-repl-history--previous (ring-length jupyter-repl-history))
(jupyter-repl-history--next (ring-length jupyter-repl-history)))
(jupyter-repl-replace-cell-code (ring-ref jupyter-repl-history 0))
(goto-char (if isearch-forward (jupyter-repl-cell-code-beginning-position)
(point-max))))
(defun jupyter-repl-history-isearch-push-state ()
"Save a function restoring the state of input history search.
Save the element at index 0 in `jupyter-repl-history'. When
restoring the state, the `jupyter-repl-history' ring is rotated,
in the appropriate direction, to the saved element."
(let ((code (jupyter-repl-cell-code)))
(cond
((equal code (ring-ref jupyter-repl-history 0))
(let ((elem (ring-ref jupyter-repl-history 0)))
(lambda (_cmd)
(when isearch-wrapped
(if isearch-forward
(jupyter-repl-history--next (ring-length jupyter-repl-history))
(jupyter-repl-history--previous (ring-length jupyter-repl-history))))
(while (not (eq (ring-ref jupyter-repl-history 0) elem))
(if isearch-forward
(jupyter-repl-history--previous 1)
(jupyter-repl-history--next 1)))
(jupyter-repl-replace-cell-code (ring-ref jupyter-repl-history 0)))))
(t
(let ((elem code))
(lambda (_cmd)
(jupyter-repl-replace-cell-code elem)))))))
;;; `jupyter-repl-mode'
(defun jupyter-repl-scratch-buffer ()
"Display a scratch buffer associated with the current REPL buffer."
(interactive)
(if (jupyter-repl-connected-p)
(let ((client jupyter-repl-current-client))
(with-current-buffer (get-buffer-create
(concat "*jupyter-scratch*"))
(funcall (jupyter-repl-language-mode client))
(jupyter-repl-associate-buffer client)
(pop-to-buffer (current-buffer))))
(error "Not in a valid REPL buffer")))
(defvar jupyter-repl-mode-map
(let ((map (make-sparse-keymap)))
(define-key map "q" nil)
(define-key map [remap backward-sentence] #'jupyter-repl-backward-cell)
(define-key map [remap forward-sentence] #'jupyter-repl-forward-cell)
(define-key map (kbd "RET") #'jupyter-repl-ret)
(define-key map (kbd "C-n") #'jupyter-repl-history-next)
(define-key map (kbd "C-p") #'jupyter-repl-history-previous)
(define-key map (kbd "M-n") #'jupyter-repl-history-next)
(define-key map (kbd "M-p") #'jupyter-repl-history-previous)
map))
;; TODO: Gaurd against a major mode change
(put 'jupyter-repl-mode 'mode-class 'special)
(define-derived-mode jupyter-repl-mode fundamental-mode
"Jupyter-REPL"
"A Jupyter REPL major mode."
(cl-check-type jupyter-repl-current-client jupyter-repl-client)
;; This is a better setting when rendering HTML tables
(setq-local truncate-lines t)
(setq-local indent-line-function #'jupyter-repl-indent-line)
(setq-local left-margin-width jupyter-repl-prompt-margin-width)
;; Initialize a buffer using the major-mode correponding to the kernel's
;; language. This will be used for indentation and to capture font lock
;; properties.
(let* ((info (oref jupyter-repl-current-client kernel-info))
(language-info (plist-get info :language_info)))
(cl-destructuring-bind (mode syntax)
(jupyter-repl-kernel-language-mode-properties language-info)
(setq-local jupyter-repl-lang-mode mode)
(setq-local jupyter-repl-lang-buffer
(get-buffer-create
(format " *jupyter-repl-lang-%s*"
(plist-get language-info :name))))
(set-syntax-table syntax)
(with-jupyter-repl-lang-buffer
(unless (eq major-mode mode)
(funcall mode)))))
;; Get history from kernel
(setq-local jupyter-repl-history
(make-ring (1+ jupyter-repl-history-maximum-length)))
;; The sentinel value keeps track of the newest/oldest elements of the
;; history since next/previous navigation is implemented by rotations on the
;; ring.
(ring-insert jupyter-repl-history 'jupyter-repl-history)
(let ((jupyter-inhibit-handlers '(:status)))
(jupyter-send-history-request jupyter-repl-current-client
:n jupyter-repl-history-maximum-length :raw nil :unique t))
(erase-buffer)
;; Add local hooks
(add-hook 'kill-buffer-query-functions #'jupyter-repl-kill-buffer-query-function nil t)
(add-hook 'after-change-functions 'jupyter-repl-after-buffer-change nil t)
(add-hook 'pre-redisplay-functions 'jupyter-repl-preserve-window-margins nil t)
;; Initialize the REPL
(buffer-disable-undo)
;; TODO: Rename to initialize-jupyter-hooks
(jupyter-repl-initialize-hooks)
(jupyter-repl-initialize-fontification)
(jupyter-repl-isearch-setup)
(jupyter-repl-sync-execution-state)
(jupyter-repl-interaction-mode))
(defun jupyter-repl-initialize-hooks ()
"Initialize startup hooks.
When the kernel restarts, insert a new prompt."
;; NOTE: This hook will only run if `jupyter-include-other-output' is non-nil
;; during the restart.
(jupyter-add-hook jupyter-repl-current-client 'jupyter-iopub-message-hook
(apply-partially
#'jupyter-repl-on-kernel-restart jupyter-repl-current-client)))
(defun jupyter-repl-initialize-fontification ()
"Initialize fontification for the current REPL buffer."
(let (fld sff)
(with-jupyter-repl-lang-buffer
(setq fld font-lock-defaults
sff font-lock-syntactic-face-function))
;; Set `font-lock-defaults' to a copy of the font lock defaults for the
;; REPL language but with a modified syntactic fontification function
(cl-destructuring-bind (kws &optional kws-only case-fold syntax-alist
&rest vars)
fld
(setq font-lock-defaults
(apply #'list kws kws-only case-fold syntax-alist
(append vars
(list
(cons 'font-lock-syntactic-face-function
;; Only fontify syntactically when the text
;; does not have a font-lock-face property
(lambda (state)
(unless (get-text-property
(nth 8 state) 'font-lock-face)
(when sff (funcall sff state))))))))))
(font-lock-mode)
;; Special case since `js2-mode' does not use `font-lock-defaults' for
;; highlighting.
(when (and (eq jupyter-repl-lang-mode 'js2-mode)
(null (nth 0 font-lock-defaults)))
(add-hook 'after-change-functions
(lambda (_beg _end _len)
(unless (jupyter-repl-cell-finalized-p)
(save-restriction
(narrow-to-region
(jupyter-repl-cell-code-beginning-position)
(jupyter-repl-cell-code-end-position))
(js2-parse)
(js2-mode-apply-deferred-properties))))
t t))))
(defun jupyter-repl-insert-banner (banner)
"Insert BANNER into the `current-buffer'.
Make the text of BANNER read only and apply the `shadow' face to
it."
(jupyter-repl-without-continuation-prompts
(let ((start (point)))
(jupyter-repl-insert banner)
(jupyter-repl-newline)
(add-text-properties start (point) '(font-lock-face shadow fontified t)))))
(defun jupyter-repl-sync-execution-state ()
"Synchronize the `jupyter-repl-current-client's kernel state."
(let* ((client jupyter-repl-current-client)
(req (let ((jupyter-inhibit-handlers t))
(jupyter-send-execute-request client :code "" :silent t))))
(jupyter-add-callback req
:status (lambda (msg)
(oset client execution-state
(jupyter-message-get msg :execution_state)))
:execute-reply (lambda (msg)
(oset client execution-count
(1+ (jupyter-message-get msg :execution_count)))))
;; FIXME: Waiting longer here to account for initial startup of the Jupyter
;; kernel. Sometimes the idle message won't be received if another long
;; running execute request is sent right after.
(jupyter-wait-until-idle req 2)))
;;; `jupyter-repl-interaction-mode'
(defun jupyter-repl-pop-to-buffer ()
"Switch to the REPL buffer of the `jupyter-repl-current-client'."
(interactive)
(if jupyter-repl-current-client
(with-jupyter-repl-buffer jupyter-repl-current-client
(goto-char (point-max))
(pop-to-buffer (current-buffer)))
(error "Buffer not associated with a REPL, see `jupyter-repl-associate-buffer'")))
(defun jupyter-repl-available-repl-buffers (&optional mode)
"Return a list of REPL buffers that are connected to live kernels.
If MODE is non-nil, return all REPL buffers whose
`jupyter-repl-lang-mode' is MODE."
(delq
nil
(mapcar (lambda (b)
(with-current-buffer b
(and (eq major-mode 'jupyter-repl-mode)
(if mode (eq mode jupyter-repl-lang-mode) t)
(jupyter-repl-connected-p)
(buffer-name b))))
(buffer-list))))
;;;###autoload
(defun jupyter-repl-associate-buffer (client)
"Associate the `current-buffer' with a REPL CLIENT.
If the `major-mode' of the `current-buffer' is the
`jupyter-repl-lang-mode' of CLIENT, enable
`jupyter-repl-interaction-mode'."
(interactive
(list
(completing-read
"jupyter-repl: "
(or (jupyter-repl-available-repl-buffers major-mode)
(error "No live REPL for `current-buffer's `major-mode'"))
nil t)))
(setq client (if (or (bufferp client) (stringp client))
(with-current-buffer client
jupyter-repl-current-client)
client))
(cl-check-type client jupyter-repl-client)
(setq-local jupyter-repl-current-client client)
(jupyter-repl-interaction-mode))
(defvar jupyter-repl-interaction-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'jupyter-repl-eval-line-or-region)
(define-key map (kbd "C-c C-l") #'jupyter-repl-eval-file)
(define-key map (kbd "C-c C-f") #'jupyter-repl-inspect-at-point)
(define-key map (kbd "C-c C-r") #'jupyter-repl-restart-kernel)
;; TODO: Change this keybinding since C-i is actually TAB and there may be
;; a more conventional command to place here.
(define-key map (kbd "C-c C-i") #'jupyter-repl-interrupt-kernel)
(define-key map (kbd "C-c C-z") #'jupyter-repl-pop-to-buffer)
map))
(defun jupyter-repl-propagate-client (orig-fun buffer-or-name &rest args)
"Propagate the `jupyter-repl-current-client' to other buffers."
(when jupyter-repl-interaction-mode
(let ((client jupyter-repl-current-client)
(buf (get-buffer buffer-or-name))
(mode major-mode))
(when buf
(with-current-buffer buf
(when (and (eq mode major-mode)
(not jupyter-repl-interaction-mode))
(jupyter-repl-associate-buffer client))))))
(apply orig-fun buffer-or-name args))
(advice-add 'switch-to-buffer :around #'jupyter-repl-propagate-client)
(define-minor-mode jupyter-repl-interaction-mode
"Minor mode for interacting with a Jupyter REPL.
When this minor mode is enabled you may evaluate code from the
current buffer using the associated REPL (see
`jupyter-repl-associate-buffer' to associate a REPL).
In addition any new buffers opened with the same `major-mode' as
the `current-buffer' will automatically have
`jupyter-repl-interaction-mode' enabled for them.
\\{jupyter-repl-interaction-map}"
:group 'jupyter-repl
:lighter " JuPy"
:init-value nil
:keymap jupyter-repl-interaction-map
(if jupyter-repl-interaction-mode
(add-hook 'completion-at-point-functions 'jupyter-completion-at-point nil t)
(unless (eq major-mode 'jupyter-repl-mode)
(kill-local-variable 'jupyter-repl-current-client))))
(defun jupyter-repl-kernel-language-mode-properties (language-info)
"Get the `major-mode' info of a kernel's language.
LANGUAGE-INFO should be the plist of the `:language_info' key in
a kernel's kernel-info. The `major-mode' is found by consulting
`auto-mode-alist' using the language's file extension found in
LANGUAGE-INFO. Return a list
(MODE SYNTAX-TABLE)
Where MODE is the `major-mode' to use for syntax highlighting
purposes and SYNTAX-TABLE is the syntax table of MODE."
(cl-destructuring-bind (&key file_extension &allow-other-keys)
language-info
(let (mode syntax)
(with-temp-buffer
(let ((buffer-file-name
(concat "jupyter-repl-lang" file_extension)))
(delay-mode-hooks (set-auto-mode))
(setq mode major-mode)
(setq syntax (syntax-table))))
(list mode syntax))))
(defun jupyter-repl--new-repl (client)
"Initialize a new REPL buffer based on CLIENT.
CLIENT is a `jupyter-repl-client' already connected to its kernel
and has a non-nil kernel-info slot.
A new REPL buffer communicating with CLIENT's kernel is created
and set as CLIENT's buffer slot. If CLIENT already has a non-nil
buffer slot, raise an error."
(if (oref client buffer) (error "Client already has a REPL buffer")
(unless (oref client kernel-info)
(error "Client needs to have valid kernel-info"))
(cl-destructuring-bind (&key language_info
banner
&allow-other-keys)
(oref client kernel-info)
(let ((language-name (plist-get language_info :name))
(language-version (plist-get language_info :version)))
(oset client buffer
(generate-new-buffer
(format "*jupyter-repl[%s]*"
(concat language-name " " language-version))))
(with-jupyter-repl-buffer client
(setq-local jupyter-repl-current-client client)
(jupyter-repl-mode)
(jupyter-repl-insert-banner banner)
(jupyter-repl-insert-prompt 'in))))))
;;;###autoload
(defun run-jupyter-repl (kernel-name &optional associate-buffer client-class)
"Run a Jupyter REPL connected to a kernel with name, KERNEL-NAME.
KERNEL-NAME will be passed to `jupyter-find-kernelspecs' and the
first kernel found will be used to start the new kernel.
Optional argument ASSOCIATE-BUFFER, if non-nil, means to enable
`jupyter-repl-interaction-mode' in the `current-buffer' and
associate it with the REPL created. When called interactively,
ASSOCIATE-BUFFER is set to t. If the `current-buffer's
`major-mode' does not correspond to the language of the kernel
started, ASSOCIATE-BUFFER has no effect.
Optional argument CLIENT-CLASS is the class that will be passed
to `jupyter-start-new-kernel' and should be a subclass of
`jupyter-repl-client', which is the default.
When called interactively, display the new REPL buffer.
Otherwise, in a non-interactive call, return the
`jupyter-repl-client' connect to the kernel."
(interactive (list (car (jupyter-completing-read-kernelspec
nil current-prefix-arg))
t
nil))
(or client-class (setq client-class 'jupyter-repl-client))
(unless (called-interactively-p 'interactive)
(setq kernel-name (caar (jupyter-find-kernelspecs kernel-name))))
(unless kernel-name
(error "No kernel found for prefix (%s)" kernel-name))
(unless (child-of-class-p client-class 'jupyter-repl-client)
(error "Class should be a subclass of `jupyter-repl-client' (`%s')" client-class))
(cl-destructuring-bind (_manager client info)
(jupyter-start-new-kernel kernel-name client-class)
(oset client kernel-info info)
(jupyter-repl--new-repl client)
(when (and associate-buffer
(eq major-mode (jupyter-repl-language-mode client)))
(jupyter-repl-associate-buffer client))
(when (called-interactively-p 'interactive)
(pop-to-buffer (oref client buffer)))
client))
;;;###autoload
(defun connect-jupyter-repl (file-or-plist &optional associate-buffer client-class)
"Run a Jupyter REPL using a kernel's connection FILE-OR-PLIST.
FILE-OR-PLIST can be either a file holding the connection
information or a property list of connection information.
ASSOCIATE-BUFFER has the same meaning as in `run-jupyter-repl'.
Optional argument CLIENT-CLASS is the class of the client that
will be used to initialize the REPL and should be a subclass of
`jupyter-repl-client', which is the default.
Return the `jupyter-repl-client' connected to the kernel. When
called interactively, display the new REPL buffer as well."
(interactive (list (read-file-name "Connection file: ") t nil))
(or client-class (setq client-class 'jupyter-repl-client))
(unless (child-of-class-p client-class 'jupyter-repl-client)
(error "Class should be a subclass of `jupyter-repl-client' (`%s')" client-class))
(let ((client (make-instance client-class)))
(jupyter-initialize-connection client file-or-plist)
(jupyter-start-channels client)
(message "Requesting kernel info...")
(let* ((jupyter-inhibit-handlers t)
(info (jupyter-wait-until-received :kernel-info-reply
(jupyter-send-kernel-info-request client)
5)))
(unless info
(destructor client)
(error "Kernel did not respond to kernel-info request"))
(oset client kernel-info (jupyter-message-content info))
(jupyter-repl--new-repl client)
(when (and associate-buffer
(eq major-mode (jupyter-repl-language-mode client)))
(jupyter-repl-associate-buffer client))
(when (called-interactively-p 'interactive)
(pop-to-buffer (oref client buffer)))
client)))
(provide 'jupyter-repl)
;; Local Variables:
;; byte-compile-warnings: (not free-vars)
;; End:
;;; jupyter-repl.el ends here