You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

296 lines
12 KiB

;;; json-process-client.el --- Interact with a TCP process using JSON -*- lexical-binding: t; -*-
;; Copyright (C) 2019 Damien Cassou
;; Author: Nicolas Petton <nicolas@petton.fr>
;; Damien Cassou <damien@cassou.me>,
;; Version: 1.0.0
;; Package-Requires: ((emacs "25.1"))
;; Url: https://gitlab.petton.fr/nico/json-process-client
;; 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 3 of the License, 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 this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library starts a process and communicates with it through JSON
;; over TCP. The process must output one JSON message per line.
;;; Code:
(require 'json)
(require 'map)
(require 'cl-lib)
;; Private variables
(cl-defstruct (json-process-client-application
(:constructor json-process-client--application-create)
(:conc-name json-process-client--application-))
(name nil :read-only t)
(process nil)
(port nil :read-only t)
(connection nil)
(executable nil :read-only t)
(args nil :read-only t)
(tcp-started-callback nil :read-only t)
(message-callbacks (make-hash-table) :read-only t)
(debug-buffer nil :read-only t)
(started-regexp nil :read-only t)
(save-callback nil :read-only t)
(exec-callback nil :read-only t)
(delete-callback nil :read-only t)
(write-id nil :read-only nil))
(defvar-local json-process-client--application nil
"Buffer-local variable to store which application the buffer corresponds to.")
(defun json-process-client--save-callback (application callback message)
"Save CALLBACK so we can call it when a response for MESSAGE arrives from APPLICATION."
(funcall (json-process-client--application-save-callback application) callback message))
(defun json-process-client--exec-callback (application response)
"Execute the callback suited to handle response RESPONSE for APPLICATION."
(funcall (json-process-client--application-exec-callback application) response))
(defun json-process-client--delete-callback (application response)
"Remove the callback suited to handle response RESPONSE for APPLICATION."
(funcall (json-process-client--application-delete-callback application) response))
;; Private functions
(defun json-process-client--ensure-process (application)
"Signal an error if the process for APPLICATION is not started."
(unless (json-process-client-process-live-p application)
(user-error "Process for application `%s' not started"
(json-process-client--application-name application))))
(defun json-process-client--start-server (application)
"Start a process for APPLICATION."
(let* ((process (apply
#'start-process
(format "%s-process" (json-process-client--application-name application))
(generate-new-buffer "*json-process-client-process*")
(json-process-client--application-executable application)
(json-process-client--application-args application))))
(setf (json-process-client--application-process application) process)
(set-process-query-on-exit-flag process nil)
(set-process-filter process (json-process-client-client--process-filter-function application))))
(defun json-process-client-client--process-filter-function (application)
"Return a process filter function for APPLICATION."
(lambda (process output)
(with-current-buffer (process-buffer process)
(goto-char (point-max))
(insert output))
;; avoid opening TCP connections multiple times:
(unless (process-live-p (json-process-client--application-connection application))
(if (string-match-p (json-process-client--application-started-regexp application) output)
(json-process-client--open-network-stream application)
(json-process-client-stop application)
(error "Application process error: %s" output)))))
(defun json-process-client--open-network-stream (application)
"Open a network connection to the TCP process for APPLICATION.
The APPLICATION's callback is evaluated once the connection is established."
(let* ((application-name (json-process-client--application-name application))
(connection-buffer (generate-new-buffer
(format "%s-connection" application-name)))
(connection (open-network-stream
(format "%s-connection" application-name)
connection-buffer
"localhost"
(json-process-client--application-port application)))
(callback (json-process-client--application-tcp-started-callback application)))
(setf (json-process-client--application-connection application) connection)
(with-current-buffer connection-buffer
(setq-local json-process-client--application application))
(set-process-filter connection #'json-process-client--connection-filter)
(set-process-coding-system connection 'utf-8)
(set-process-query-on-exit-flag connection nil)
(when callback (funcall callback))))
(defun json-process-client--connection-filter (process output)
"Filter function for handling the PROCESS OUTPUT."
(let ((buf (process-buffer process)))
(with-current-buffer buf
(save-excursion
(goto-char (point-max))
(insert output)))
(json-process-client--handle-data buf)))
(defun json-process-client--handle-data (buffer)
"Handle process data in BUFFER.
Read all complete JSON messages from BUFFER and delete them."
(with-current-buffer buffer
(when (json-process-client--complete-message-p)
(save-excursion
(goto-char (point-min))
(let ((application json-process-client--application)
(data (json-read)))
(delete-region (point-min) (point))
;; Remove the linefeed char
(delete-char 1)
(json-process-client--handle-message application data)
(json-process-client--handle-data buffer))))))
(defun json-process-client--complete-message-p ()
"Return non-nil if the current buffer has at least one complete message.
Messages end with a line feed."
(save-excursion
;; start from (point-max) because the probability to find a \n
;; there is higher.
(goto-char (point-max))
(search-backward "\n" nil t)))
(defun json-process-client--handle-message (application data)
"Handle a server message with DATA for APPLICATION."
(let ((debug-buffer (json-process-client--application-debug-buffer application)))
(when (bufferp debug-buffer)
(with-current-buffer debug-buffer
(goto-char (point-max))
(insert (format "Received: %s\n\n" data)))))
(unwind-protect
(json-process-client--exec-callback application data)
(json-process-client--delete-callback application data)))
;; Public functions
(cl-defun json-process-client-start (&key name executable port started-regexp tcp-started-callback save-callback exec-callback delete-callback debug args)
"Start a process using EXECUTABLE. Return an application object.
NAME is a short string describing the application. It is used to
name processes and buffers.
PORT is a number indicating which TCP port to connect to reach
EXECUTABLE.
STARTED-REGEXP should match the process output when the process
is listening to TCP connections.
Evaluate TCP-STARTED-CALLBACK once the TCP connection is ready.
SAVE-CALLBACK, EXEC-CALLBACK and DELETE-CALLBACK should be three
functions used to associate callbacks to TCP messages and
responses.
If DEBUG is non-nil, send all messages to a debug buffer. If
DEBUG is a string, use this as the name for the debug buffer.
ARGS are passed to EXECUTABLE."
(let* ((executable-path (executable-find executable))
(debug-buffer (when debug
(get-buffer-create
(if (stringp debug)
debug
(format "*json-process-client-%s*" name)))))
(application (json-process-client--application-create
:name name
:executable executable-path
:port port
:args args
:tcp-started-callback tcp-started-callback
:save-callback save-callback
:exec-callback exec-callback
:delete-callback delete-callback
:started-regexp started-regexp
:debug-buffer debug-buffer)))
(unless executable-path
(user-error "Cannot find executable \"%s\"" executable))
(when (bufferp debug-buffer)
(with-current-buffer debug-buffer
(erase-buffer)))
(json-process-client--start-server application)
application))
(cl-defun json-process-client-start-with-id (&key name executable port started-regexp tcp-started-callback exec-callback debug args)
"Like `json-process-client-start' but maps responses to callbacks using ids.
The parameters NAME, EXECUTABLE, PORT, STARTED-REGEXP,
TCP-STARTED-CALLBACK, EXEC-CALLBACK, DEBUG, and ARGS are the same
as in `json-process-client-start'.
This function is simpler to use than `json-process-client-start'
because it doesn't require managing a response-to-callback
mapping manually. Nevertheless, it can only be useful if the
process pointed to by EXECUTABLE reads ids from the messages and
writes them back in its responses."
(let* ((callbacks (list))
(application
(json-process-client-start
:name name
:executable executable
:port port
:started-regexp started-regexp
:tcp-started-callback tcp-started-callback
:save-callback (lambda (callback message)
(map-put callbacks (map-elt message 'id) callback))
:exec-callback (lambda (response)
(funcall
exec-callback
response
(map-elt callbacks (map-elt response 'id))))
:delete-callback (lambda (response)
(map-delete callbacks (map-elt response 'id)))
:debug debug
:args args)))
(let ((id 0))
(setf (json-process-client--application-write-id application)
(lambda ()
(cl-incf id)
`(id . ,id))))
application))
(defun json-process-client-stop (application)
"Stop the process and connection for APPLICATION."
(when (json-process-client-application-p application)
(let ((connection (json-process-client--application-connection application))
(process (json-process-client--application-process application)))
(when (process-live-p connection)
(kill-buffer (process-buffer process))
(kill-buffer (process-buffer connection))))
(setf (json-process-client--application-connection application) nil)
(setf (json-process-client--application-process application) nil)))
(defun json-process-client-send (application message &optional callback)
"Send MESSAGE to APPLICATION.
When CALLBACK is non-nil, evaluate it with the process response."
(json-process-client--ensure-process application)
(let* ((message (if (json-process-client--application-write-id application)
(cons (funcall (json-process-client--application-write-id application)) message)
message))
(json (json-encode message))
(debug-buffer (json-process-client--application-debug-buffer application)))
(json-process-client--save-callback application callback message)
(when (bufferp debug-buffer)
(with-current-buffer debug-buffer
(goto-char (point-max))
(insert (format "Sent: %s\n\n" message))))
(process-send-string
(json-process-client--application-connection application)
(format "%s\n" json))))
(defun json-process-client-process-live-p (application)
"Return non-nil if the process for APPLICATION is running."
(and
(json-process-client-application-p application)
(process-live-p (json-process-client--application-process application))))
(provide 'json-process-client)
;;; json-process-client.el ends here