-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathshx.el
More file actions
1210 lines (1061 loc) · 45.5 KB
/
Copy pathshx.el
File metadata and controls
1210 lines (1061 loc) · 45.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; shx.el --- Extras for the comint-mode shell -*- lexical-binding: t -*-
;; Author: Chris Rayner and contributors
;; Maintainer: Chris Rayner <dchrisrayner@gmail.com>
;; Created: May 23 2011
;; Keywords: terminals, processes, comint, shell, repl
;; URL: https://github.com/riscy/shx-for-emacs
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Package-Requires: ((emacs "24.4"))
;; Version: 1.5.2
;;; Commentary:
;; shx ("shell-extras") extends comint-mode: it parses markup in the output
;; stream, enabling plots and graphics to be embedded, and adds command-line
;; functions which plug into Emacs (e.g. use :e <filename> to edit a file).
;; Type M-x shx RET to create a new shell session using shx.
;; Type M-x customize-group RET shx RET to see customization options.
;; You can enable shx in every comint-mode buffer with (shx-global-mode 1).
;;; Code:
(require 'comint)
(require 'files)
(require 'shell)
(require 'subr-x)
(eval-when-compile (defvar evil-state) (defvar tramp-syntax))
(declare-function evil-insert-state "ext:evil-states.el" (&optional arg) t)
;;; customization options and other variables
(defgroup shx nil
"Extras for the (comint-mode) shell."
:prefix "shx-"
:group 'comint
:link '(url-link :tag "URL" "https://github.com/riscy/shx-for-emacs")
:link '(emacs-commentary-link :tag "Commentary" "shx.el"))
(defcustom shx-disable-undo nil
"Whether to automatically disable undo in shx buffers."
:type 'boolean)
(defcustom shx-path-to-convert "magick"
"Path to ImageMagick's convert binary."
:type 'string)
(defcustom shx-mode-lighter " shx"
"Lighter for the shx minor mode."
:type 'string)
(defcustom shx-path-to-gnuplot "gnuplot"
"Path to gnuplot binary."
:type 'string)
(defcustom shx-img-height 300
"The height at which inlined images and plots are displayed."
:type 'integer)
(defcustom shx-use-magic-insert t
"Whether to dynamically modify input using `shx-magic-insert'.
If you change this you'll have to reload shx or restart Emacs."
:link '(function-link shx-magic-insert)
:type 'boolean)
(defcustom shx-leader ":" "Prefix for calling user commands." :type 'regexp)
(defcustom shx-comint-advise t
"Whether to advise the behavior of a number of `comint-mode' functions."
:type 'boolean)
(defcustom shx-flash-prompt-time 0.25
"Length of time (in seconds) the prompt flashes, when so advised."
:link '(function-link shx-flash-prompt)
:type 'float)
(defcustom shx-show-hints t
"Whether to echo hints when running certain commands."
:type 'boolean)
(defcustom shx-triggers
'(("https?://[A-Za-z0-9,./?=&;_-]+[[:graph:].\"'>)]+" . shx--parse-url))
"Triggers of the form: (regexp . function)."
:type '(alist :key-type regexp :value-type function))
(defcustom shx-directory-tracker-regexp nil
"Input regexp that triggers the `shell-resync-dirs' command."
:link '(function-link shx--directory-tracker)
:type '(choice regexp null))
(defcustom shx-kept-commands nil
"Shell commands of the form (description . command)."
:link '(function-link shx-cmd-kept)
:link '(function-link shx-cmd-keep)
:type '(alist :key-type string :value-type string))
(defcustom shx-max-input most-positive-fixnum
"The largest input allowed in characters.
A good value on macOS is 1024, the size of the typeahead buffer;
or, set the terminal to canonical mode with `stty -icanon`."
:type 'integer)
(defcustom shx-max-output most-positive-fixnum
"The length at which an output line is long enough to be broken.
Setting this to 1024 can lead to enormous performance gains, but
sacrifices the soundness of shx's markup and trigger matching."
:link '(function-link shx--break-long-line-maybe)
:type 'integer)
(defvar shx-cmd-prefix "shx-cmd-" "Prefix for user-command functions.")
(defvar shx-cmd-syntax
"\\([[:graph:]]+\\)[[:space:]]*\\(.*[[:graph:]]?\\)"
"Regex for recognizing shx commands in input or markup.")
(defvar shx-markup-syntax
(concat "^<" shx-cmd-syntax ">\n")
"Regex for recognizing shx commands in markup.")
(defvar shx-mode-map
(let ((keymap (make-sparse-keymap)))
(when shx-use-magic-insert
(define-key keymap " " #'shx-magic-insert)
(define-key keymap "q" #'shx-magic-insert))
;; different RET keybindings to support different Emacs environments
(define-key keymap (kbd "RET") #'shx-send-input-or-open-thing)
(define-key keymap (kbd "<return>") #'shx-send-input-or-open-thing)
(define-key keymap (kbd "C-<return>") #'shx-send-input-or-copy-line)
keymap)
"Keymap for shx.")
(defvar shx-click-file
(let ((keymap (make-sparse-keymap)))
(define-key keymap [mouse-1] #'ffap-at-mouse)
keymap)
"Keymap for capturing mouse clicks on files/URLs.")
(defvar-local shx-buffer nil "Local reference to the shx buffer.")
(defvar-local shx-prompt-overlay nil "Overlay used to flash the prompt.")
(defvar-local shx-urls nil "Local record of URLs seen.")
(defvar-local shx--old-undo-disabled nil
"Whether undo was disabled before `shx-mode' was enabled.")
(defvar-local shx--asynch-point nil)
(defvar-local shx--asynch-calling-buffer nil)
;;; input
(defun shx-send-input-or-open-thing ()
"Open thing at point, or send input if no identifiable thing."
(interactive)
(if (shx-point-on-input-p) (shx-send-input) (find-file-at-point)))
(defun shx-send-input-or-copy-line ()
"Copy current line to prompt, or send input if at the prompt."
(interactive)
(if (shx-point-on-input-p)
(shx-send-input)
(let ((line
(string-trim
(buffer-substring-no-properties
(line-beginning-position)
(line-end-position)))))
(goto-char (point-max))
(insert line))))
(defun shx-send-input ()
"Send or parse the input currently written at the prompt.
In normal circumstances this input is additionally filtered by
`shx-filter-input' via `comint-mode'."
(interactive)
(cond
((not (comint-check-proc shx-buffer))
(shx--restart-shell))
((>= (length (shx--current-input)) shx-max-input)
(user-error "Input line exceeds `shx-max-input'"))
(t
(shx--propertize-prompt)
(remove-text-properties
(process-mark (get-buffer-process (current-buffer)))
(point-max)
'(field))
(comint-send-input))))
(defun shx-filter-input (process input)
"Before sending to PROCESS, filter the INPUT.
That means, if INPUT is a shx-command, do that command instead.
This function overrides `comint-input-sender'."
(let* ((regexp (concat "^" shx-leader shx-cmd-syntax))
(match (string-match regexp (string-trim-left input)))
(shx-cmd (and match (shx--get-user-cmd (match-string 1 input)))))
(if (not shx-cmd)
(comint-simple-send process input)
(condition-case-unless-debug error-descriptor
(funcall shx-cmd (substitute-env-vars (match-string 2 input) t))
(error (shx-insert 'error (error-message-string error-descriptor) "\n")))
(with-current-buffer (process-buffer process)
;; advance the process mark to trick comint-mode
(set-marker (process-mark process) (point)))
;; send a blank to fetch a new prompt
(when (process-live-p process) (comint-send-string process "\n")))))
(defun shx--directory-tracker (input)
"Check INPUT for prefixes that require a call to `shell-resync-dirs'.
This is similar to `shell-mode's `shell-directory-tracker'. Adjust the
behavior of this function by modifying `shx-directory-tracker-regexp'."
(and shell-dirtrackp shx-directory-tracker-regexp
(string-match shx-directory-tracker-regexp input 0)
(shx--asynch-funcall #'shell-resync-dirs)))
(defun shx--propertize-prompt ()
"Add a mouseover timestamp and `default-directory' info to the last prompt."
(let ((inhibit-read-only t)
(inhibit-field-text-motion t))
(add-text-properties
(line-beginning-position)
(process-mark (get-buffer-process (current-buffer)))
`(help-echo ,(format-time-string "%x %X") shx-cwd ,default-directory))))
;;; output
(defun shx-parse-output-hook (&optional _output)
"Hook to parse the output stream."
(shx--parse-output-for-markup)
(shx--break-long-line-maybe)
(when shx-triggers (shx--parse-output-for-triggers)))
(defun shx--parse-output-for-markup ()
"Look for markup in the latest output from the process."
(save-excursion
(shx--goto-last-input-or-output)
(let ((originating-buffer shx-buffer))
(while (shx--search-forward shx-markup-syntax)
(let ((command (shx--get-user-cmd (match-string 1)))
(args (match-string 2)))
(cond
((not command)
nil)
((not (shx--safe-as-markup-p command))
(add-text-properties
(line-beginning-position)
(line-end-position)
`(help-echo "shx: this markup was unsafe/undefined")))
(t
(replace-match "") ; hide the markup
(funcall command args)
(set-buffer originating-buffer))))))))
(defun shx--parse-output-for-triggers ()
"Look for triggers in the latest output from the process (e.g. URLs)."
(dolist (trigger shx-triggers nil)
(save-excursion
(shx--goto-last-input-or-output)
(let ((originating-buffer shx-buffer))
(while (shx--search-forward (car trigger))
;; emacs 25 had/has a bug where save-window-excursion moves the point
;; backward in the calling buffer (some funcalls might use
;; save-window-excursion) which can cause infinite triggering. For
;; now, handle this by wrapping the funcall in save-excursion.
(save-excursion (funcall (cdr trigger)))
(set-buffer originating-buffer))))))
(defun shx--goto-last-input-or-output ()
"Go to the beginning of the latest output from the process."
(goto-char
(if (marker-position comint-last-output-start)
(max comint-last-output-start comint-last-input-end)
comint-last-input-end))
(forward-line 0))
(defun shx--search-forward (pattern)
"Search forward from the current point for PATTERN.
But don't search the last line, which may be incomplete."
(when (< (line-end-position) (point-max))
(re-search-forward pattern nil t)))
(defun shx--break-long-line-maybe ()
"Break the current line if it's longer than `shx-max-output'."
(when (> (current-column) shx-max-output)
(or
(re-search-backward "\\s-" (- (point) shx-max-output) t)
(backward-char))
(insert-char ?\n)
(goto-char (point-max))))
;;; util
(defun shx-browse-urls ()
"Prompt user for a URL to browse from the list `shx-urls'."
(interactive)
(let ((urls shx-urls)) ; clone url list so user edits don't modify the entries
(browse-url (completing-read "URL: " urls))))
(defun shx-describe-command (shx-command)
"Try to describe the named SHX-COMMAND."
(let ((prefix (concat shx-cmd-prefix shx-command)))
(if (functionp (intern prefix))
(describe-function (intern prefix))
(let ((comp
(completing-read "Complete shx command: "
(shx--all-commands)
nil t prefix)))
(describe-function (intern comp))))))
(defun shx--all-commands (&optional without-prefix)
"Return a list of all shx commands.
With non-nil WITHOUT-PREFIX, strip `shx-cmd-prefix' from each.
>> (member \"shx-cmd-delay\" (shx--all-commands))
=> (\"shx-cmd-delay\") ; i.e., not nil
>> (not (member \"shx-cmd-prefix\" (shx--all-commands)))
=> t"
(declare (side-effect-free t))
(mapcar
(lambda (cmd)
(if without-prefix (string-remove-prefix shx-cmd-prefix cmd) cmd))
(all-completions shx-cmd-prefix obarray #'functionp)))
(defun shx-point-on-input-p ()
"Check if point is on the input region."
(declare (side-effect-free t))
(or
(eobp)
(let ((process (get-buffer-process (current-buffer))))
(and process (>= (point-marker) (process-mark process))))))
(defun shx-tokenize (str)
"Turn STR into a list of tokens, or nil if parsing fails.
This is robust to various styles of quoting and escaping."
(declare (side-effect-free t) (pure t))
(setq str
(shx--replace-from-list
;; protect escaped single/double quotes and spaces:
'(("\\\\'" "") ("\\\\ " "") ("\\\\\"" "") ; nofmt
("'" "\"") ; prefer double quoting
("\\\\\\(.\\)" "\\1")) ; remove escape chars
str))
(mapcar
(lambda (token)
(shx--replace-from-list
'(("" "'")
("" " ")
("" "\""))
token))
(ignore-errors (split-string-and-unquote str))))
(defun shx--replace-from-list (patterns str)
"Replace multiple PATTERNS in STR -- in the supplied order."
(declare (side-effect-free t) (pure t))
(dolist (pattern patterns nil)
(setq str (replace-regexp-in-string (car pattern) (cadr pattern) str)))
str)
(defun shx-tokenize-filenames (str)
"Turn STR into a list of filenames, or nil if parsing fails.
If any path is absolute, prepend `comint-file-name-prefix' to it."
(declare (side-effect-free t))
(mapcar
(lambda (filename)
(cond
((not (file-name-absolute-p filename))
filename)
(t (concat comint-file-name-prefix filename))))
(shx-tokenize str)))
(defun shx--hint (format-string &rest args)
"Show a hint containing FORMAT-STRING with optional ARGS."
(when shx-show-hints (apply #'message (cons format-string args))))
(defun shx--current-prompt ()
"Return text from start of line to current `process-mark'."
(declare (side-effect-free t))
(cond
((get-buffer-process (current-buffer))
(save-excursion
(goto-char (point-max))
(let ((inhibit-field-text-motion t))
(buffer-substring-no-properties
(line-beginning-position)
(process-mark (get-buffer-process (current-buffer)))))))
(t (user-error "There is no process") "")))
(defun shx--current-input ()
"Return what's written after the prompt."
(declare (side-effect-free t))
(buffer-substring
(process-mark (get-buffer-process (current-buffer)))
(line-end-position)))
(defun shx--get-timer-list ()
"Get the list of resident timers."
(declare (side-effect-free t))
(let ((timer-list-1
(mapcar
(lambda (timer) (when (shx--timer-by-shx-p timer) timer))
timer-list)))
;; sort the timers for consistency
(sort
(remove nil timer-list-1)
(lambda (first-timer second-timer)
(string<
(format "%s" (aref first-timer 5))
(format "%s" (aref second-timer 5)))))))
(defun shx--timer-by-shx-p (timer)
"Return non-nil if TIMER was created by shx."
(declare (side-effect-free t))
(string-prefix-p
"(lambda nil (shx--auto"
(format "%s" (aref timer 5))))
(defun shx--get-user-cmd (cmd-prefix)
"Return user command prefixed by CMD-PREFIX, or nil."
(declare (side-effect-free t))
(let* ((prefix (format "%s%s" shx-cmd-prefix (downcase cmd-prefix)))
(completion (try-completion prefix obarray #'functionp)))
(when completion
(let ((user-cmd (intern (if (eq completion t) prefix completion))))
(when (functionp user-cmd) user-cmd)))))
(defun shx--parse-url ()
"Add a matched URL to `shx-urls' and apply `shx-click-file'."
(let ((url (match-string-no-properties 0)))
(unless (string= url (car shx-urls)) (push url shx-urls)))
(add-text-properties
(match-beginning 0)
(match-end 0)
`(keymap ,shx-click-file mouse-face highlight font-lock-face link)))
(defun shx--restart-shell (&optional new-directory)
"Guess the shell command and use `comint-exec' to restart.
If optional NEW-DIRECTORY is set, use that for `default-directory'."
;; This can be tricky, so be proactive about telling the user what's going on
(let ((default-directory (or new-directory default-directory)))
(when (file-remote-p default-directory)
(message "Restarting shell at %s (C-g to stop)" default-directory))
(let ((cmd (shx--shell-command)))
(shx-insert 'font-lock-doc-face "\n" cmd " at " default-directory "\n")
;; manually align comint-file-name-prefix with the default-directory:
(setq-local comint-file-name-prefix
(or (file-remote-p default-directory) ""))
(comint-exec (current-buffer) (buffer-name) cmd nil nil)
;; since tramp overrides `shell-file-name' with "/bin/sh" when remote:
(setq-local explicit-shell-file-name cmd)))
;; if all that was successful, commit to the new default directory:
(when new-directory (setq default-directory new-directory))
(when (file-remote-p default-directory)
(shx--hint "Return to the localhost with '%sssh'" shx-leader)))
(defun shx--shell-command ()
"Get the shell command, even if on a remote host or container."
(declare (side-effect-free t))
(let* ((remote-id (or (file-remote-p default-directory) ""))
;; guess which shell command to run per `shell' convention:
(cmd (or explicit-shell-file-name (getenv "ESHELL") shell-file-name)))
(cond
((file-exists-p (concat remote-id cmd))
cmd)
(t (completing-read "Shell command: " nil nil nil "/bin/sh" nil)))))
(defun shx--match-last-line (regexp)
"Return a form to find REGEXP on the last line of the buffer."
`(lambda (bound)
(let ((inhibit-field-text-motion t))
(when (eq (point-max) (line-end-position))
(re-search-forward ,regexp bound t)))))
(defun shx--quote-regexp (delimiter &optional escape max-length)
"Regexp matching strings delimited by DELIMITER.
ESCAPE is the string that can be used to escape the delimiter
\(defaults to backslash; ignored when set to the empty string).
MAX-LENGTH is the length of the longest match (default 300)."
(declare (side-effect-free t))
(setq escape (or escape "\\\\"))
(concat delimiter
"\\("
(unless (string= "" escape)
(concat escape escape "\\|" ; two escapes OR
escape delimiter "\\|")) ; escaped delimiter
"[^" delimiter "]"
"\\)"
"\\{0,"
(format "%d" (or max-length 300))
"\\}"
delimiter))
(defun shx--safe-as-markup-p (command)
"Return t if COMMAND is safe to call to generate markup.
In particular whether \"(SAFE)\" prepends COMMAND's docstring."
(declare (side-effect-free t))
(let ((doc (documentation command)))
(and doc (string-prefix-p "(SAFE)" doc))))
(defun shx--reveal-kept-commands (&optional regexp insert-kept-command)
"Add commands from `shx-kept-commands' into `comint-input-ring'.
REGEXP filters which commands to add. If INSERT-KEPT-COMMAND is
not nil, then insert the command into the current buffer."
(dolist (command shx-kept-commands nil)
(when (string-match
(or regexp ".")
(concat (car command) (cdr command)))
(when insert-kept-command (ring-insert comint-input-ring (cdr command)))
(shx-insert 'font-lock-constant-face
(car command)
": "
'font-lock-string-face command
(cdr command)
"\n"))))
;;; sending/inserting
(defun shx-magic-insert ()
"Insert the key pressed or dynamically change the input.
`comint-magic-space' completes substitutions like '!!', '!*', or
'^pattern^replacement' and, if the prompt is a colon, SPC and q
are sent straight through to the process to handle paging."
(interactive)
(let ((on-input (shx-point-on-input-p)))
(if (and on-input
(string-match "^\\s-*$" (shx--current-input))
(string-match ":$" (shx--current-prompt)))
(progn
(shx--hint "Sending '%s'" (this-command-keys))
(process-send-string nil (this-command-keys)))
(unless on-input (goto-char (point-max)))
(if shx-use-magic-insert (comint-magic-space 1) (self-insert-command 1)))))
(defun shx-cat (&rest args)
"Like `concat' but ARGS can be strings or face names."
(declare (side-effect-free t))
(let ((string "")
(face nil))
(dolist (arg args nil)
(cond
((stringp arg)
(setq string
(concat string
(propertize arg 'font-lock-face face 'rear-nonsticky t))))
((facep arg)
(setq face arg))))
string))
(defun shx-insert (&rest args)
"Insert ARGS as an output field, combined using `shx-cat'."
(insert (propertize (apply #'shx-cat args) 'field 'output)))
(defun shx-insert-timer-list ()
"Insert a list of the Emacs timers currently in effect."
(let ((sorted-timer-list (shx--get-timer-list)))
(dotimes (timer-number (length sorted-timer-list))
(shx--insert-timer
(1+ timer-number)
(nth timer-number sorted-timer-list))
(shx-insert "\n"))
(shx-insert "Active timers: " 'font-lock-constant-face
(format "%d\n" (length sorted-timer-list)))))
(defun shx-insert-image (filename)
"Insert image FILENAME into the buffer."
(let* ((img-name (make-temp-file "tmp" nil ".png"))
(status
(call-process
shx-path-to-convert nil t nil
(expand-file-name filename)
"-resize"
(format "x%d>" shx-img-height)
img-name)))
(when (zerop status)
(let ((pos (point)))
(insert-image (create-image img-name))
(add-text-properties pos (point) `(help-echo ,filename)))))
(shx-insert "\n")
(shx-show-output))
(defun shx-insert-plot (filename plot-command line-style)
"Prepare a plot of the data in FILENAME.
Use a gnuplot specific PLOT-COMMAND (for example `plot`) and
LINE-STYLE (for example `w lp`); insert the plot in the buffer."
(let* ((img-name (make-temp-file "tmp" nil ".png"))
(color (face-attribute 'default :foreground))
(filename (shx--shell-quote-no-quotation-marks filename))
(status
(call-process
shx-path-to-gnuplot nil t nil "-e"
(concat "set term png transparent truecolor;"
"set border lc rgb \"" color "\";"
"set out \"" img-name "\";"
plot-command "\"" filename "\"" line-style " notitle"))))
(when (zerop status) (shx-insert-image img-name))))
(defun shx--shell-quote-no-quotation-marks (str)
"Shell-quote STR, but strip the \"s added in some `system-type's."
(replace-regexp-in-string ; NOTE: in Emacs 26+ we can use `string-trim'
"\"$" ""
(replace-regexp-in-string "^\"" "" (shell-quote-argument str))))
(defun shx--insert-timer (timer-number timer)
"Insert a line of the form '<TIMER-NUMBER> <TIMER>'."
(shx-insert
'font-lock-constant-face
(format "%d. " timer-number)
'font-lock-string-face
(format "%s" (shx--format-timer-string timer))
(when (aref timer 4)
(format "\s(pulse: %d)" (aref timer 4)))))
(defun shx--format-timer-string (timer)
"Create a human-readable string out of TIMER."
(declare (side-effect-free t))
(let* ((str (format "%s" (aref timer 5)))
(output
(string-remove-prefix
"(lambda nil (shx--auto "
(string-remove-suffix "))" str))))
(concat "[" output "]")))
;;; asynch functions
(defun shx--asynch-funcall (function &optional args delay)
"Run FUNCTION with ARGS in the buffer after a short DELAY."
(run-at-time
(or delay 0.2)
nil
`(lambda () (with-current-buffer ,shx-buffer ,(cons function args)))))
(defun shx--asynch-run (command)
"Run shell COMMAND asynchronously; bring the results over when done.
If a process is already running in the shx-asynch buffer, kill it."
(let ((output-buffer (get-buffer-create " *shx-asynch*")))
(when (get-buffer-process output-buffer)
(kill-process (get-buffer-process output-buffer))
(while (get-buffer-process output-buffer) (sleep-for 0.01)))
(setq-local shx--asynch-point (point))
(shx-insert 'font-lock-comment-face "Wait..." 'default "\n")
(let ((calling-buffer shx-buffer))
(save-window-excursion (async-shell-command command output-buffer))
(set-buffer output-buffer)
(setq-local shx--asynch-calling-buffer calling-buffer)
(let ((process (get-buffer-process output-buffer)))
(set-process-sentinel process #'shx--asynch-sentinel)
(set-process-query-on-exit-flag process nil)))))
(defun shx--asynch-sentinel (process signal)
"Sentinel called when PROCESS sees SIGNAL."
(when (memq (process-status process) '(exit signal))
(set-buffer (process-buffer process))
(let* ((out (buffer-substring (point-min) (point-max))))
(set-buffer shx--asynch-calling-buffer)
(when (>= shx--asynch-point (point-max))
(setq-local shx--asynch-point 0))
(save-excursion
(goto-char shx--asynch-point)
(let ((inhibit-read-only t))
(shx-insert 'font-lock-comment-face (capitalize signal) 'default out)
(unless (= 0 shx--asynch-point)
(delete-region
(line-beginning-position)
(min (point-max) (1+ (line-end-position))))))))))
(defun shx--delay-input (delay input &optional buffer repeat-interval)
"After DELAY, process INPUT in the BUFFER.
If BUFFER is nil, process in the current buffer. Optional
REPEAT-INTERVAL specifies delays between repetitions."
(let* ((process (get-buffer-process (buffer-name buffer)))
(funcall `(lambda () ,(cons #'shx--auto (list process input)))))
(run-at-time delay repeat-interval funcall)))
(defun shx--auto (process command)
"Send PROCESS a COMMAND.
\(Makes the `shx-insert-timer-list' listing easier to parse.)"
(process-send-string process (concat command "\n")))
;;; asynch user commands
(defun shx-cmd-delay (args)
"Run a command after a specific delay.
ARGS are <delay in seconds> <command>.
Cancel a delayed command with :stop (`shx-cmd-stop').
\nExample:\n
:delay 10 echo Ten seconds are up!"
(cond
((string-match "^\\([0-9.]+\\)\\s-+\\(.+\\)$" args)
(let ((delay (match-string 1 args))
(command (match-string 2 args)))
(shx-insert "Delaying " 'comint-highlight-input command
'default
(format " %s seconds\n" delay))
(shx--delay-input (concat delay " sec") command))
(shx--hint "cancel a delayed command with :stop"))
(t (shx-insert 'error "delay <delay> <command>\n"))))
(defun shx-cmd-pulse (args)
"Repeat a shell command indefinitely with a given delay.
ARGS are a string of the form '<delay in seconds> <command>'.
Cancel a pulsing command with :stop (`shx-cmd-stop').
\nExample:\n
:pulse 10 date"
(cond
((string-match "^\\([0-9.]+\\)\\s-+\\(.+\\)$" args)
(let ((delay (string-to-number (match-string 1 args)))
(command (match-string 2 args)))
(shx-insert "Pulsing " 'comint-highlight-input command
'default
(format " every %d seconds\n" delay))
(shx--delay-input 0 command nil delay))
(shx--hint "cancel a pulsing command with :stop"))
(t (shx-insert 'error "pulse <delay> <command>\n"))))
(defun shx-cmd-repeat (args)
"Repeat a shell command a number of times with a given delay.
ARGS are <count> <delay in seconds> <command>.
Cancel a repeating command with :stop (`shx-cmd-stop').
\nExample:\n
:repeat 3 1 echo Echo... echo... echo..."
(cond
((string-match "^\\([0-9]+\\)\\s-+\\([0-9.]+\\)\\s-+\\(.+\\)$" args)
(let ((reps (string-to-number (match-string 1 args)))
(delay (string-to-number (match-string 2 args)))
(command (match-string 3 args)))
(shx-insert "Repeating " 'comint-highlight-input command 'default
(format " %d times every %d seconds\n" reps delay))
(dotimes (ii reps)
(shx--delay-input (* (1+ ii) delay) command)))
(shx--hint "cancel a repeating command with :stop"))
(t (shx-insert 'error "repeat <count> <delay> <command>\n"))))
(defun shx-cmd-stop (timer-number)
"(SAFE) Stop the specified shx timer.
If a TIMER-NUMBER is not supplied, enumerate all shx timers.
\nExamples:\n
:stop
:stop 3"
(setq timer-number (1- (string-to-number timer-number)))
(let ((shx-timer-list (shx--get-timer-list)))
(and
(>= timer-number 0)
(< timer-number (length shx-timer-list))
(let ((timer (nth timer-number shx-timer-list)))
(shx-insert "Stopped " 'font-lock-string-face
(shx--format-timer-string timer)
"\n")
(cancel-timer timer))))
(shx-insert-timer-list))
;;; general user commands
(defun shx-cmd-alert (string)
"(SAFE) Show the `shx-buffer' in the other window with STRING."
(message "From %s at %s: '%s'" shx-buffer (format-time-string "%X") string)
(display-buffer shx-buffer))
(defun shx-cmd-clear (_args)
"(SAFE) Clear the buffer."
;; this is `comint-clear-buffer' from Emacs >= 25 :
(let ((comint-buffer-maximum-size 0)) (comint-truncate-buffer)))
(defun shx-cmd-date (_args)
"(SAFE) Show the date."
(shx-insert (current-time-string) "\n"))
(defun shx-cmd-diff (files)
"(SAFE) Launch an Emacs `ediff' between FILES.
\nExample:\n
:diff file1.txt \"file 2.csv\""
(setq files (shx-tokenize-filenames files))
(if (/= (length files) 2)
(shx-insert 'error "diff <file1> <file2>" "\n")
(shx-insert "Diffing " 'font-lock-doc-face
(car files)
'default
" and " 'font-lock-doc-face
(cadr files)
"\n")
(shx--asynch-funcall #'ediff (mapcar #'expand-file-name files))))
(defun shx-cmd-edit (file)
"(SAFE) Open FILE in the current window.
\nExamples:\n
:e directory/to/file
\nOr edit a remote file using `tramp':\n
:e /ssh:user@server#port:directory/to/file
:e /docker:02fbc948e009:/directory/to/file"
(setq file (car (shx-tokenize-filenames file)))
(if (or (string= "" file) (not file))
(shx-insert 'error "Couldn't parse filename" "\n")
(shx-insert "Editing " 'font-lock-doc-face file "\n")
(shx--asynch-funcall #'find-file (list (expand-file-name file) t))))
(defalias 'shx-cmd-e #'shx-cmd-edit)
(defun shx-cmd-eval (sexp)
"Evaluate the elisp SEXP.
\nExamples:\n
:eval (format \"%d\" (+ 1 2))
:eval (* 2 (+ 3 5))"
(let ((originating-buffer (current-buffer))
(output
(prin1-to-string (eval (car (read-from-string sexp))))))
(with-current-buffer originating-buffer
(shx-insert 'font-lock-constant-face "=> " output "\n"))))
(defun shx-cmd-find (pattern)
"Run fuzzy find for PATTERN.
Depending on the contents of the current directory, this command
may take a while and unfortunately blocks Emacs in the meantime.
\nExamples:\n
:f prefix
:f *suffix"
(if (equal pattern "")
(shx-insert 'error "find <prefix>" "\n")
(let ((pattern (mapconcat #'char-to-string (string-to-list pattern) "*"))
(filters "-not -path '*/.*' -not -path '*/__pycache__/*'"))
(shx--asynch-run (format "find $PWD %s -iname '%s*'" filters pattern)))))
(defun shx-cmd-pipe (command)
"Pipe the output of COMMAND to a compilation buffer.
\nExamples:\n
:pipe make
:pipe git repack -a -d --depth=250 --window=250"
(if (equal command "")
(shx-insert 'error "pipe <command>" "\n")
(switch-to-buffer-other-window "*shx-pipe*")
(let ((compilation-buffer-name-function (lambda (_mode) "*shx-pipe*")))
(shx-insert "Piping "
'comint-highlight-input command 'default
" to " "*shx-pipe*\n")
(compile command t))))
(defun shx-cmd-g (pattern)
"Launch a recursive grep for PATTERN."
(grep (format "grep -irnH '%s' *" pattern)))
(defun shx-cmd-goto-url (_arg)
"Go to a a URL, offering completions from the buffer."
(shx--asynch-funcall #'shx-browse-urls))
(defun shx-cmd-grep (args)
"Launch a grep using the supplied command line ARGS.
\nExamples:\n
:grep -r pattern *
:grep pattern * | grep -v exclusion"
(grep (format "grep -nH %s" args)))
(defun shx-cmd-header (header)
"(SAFE) Set the header-line to HEADER.
See `header-line-format' for formatting options.
\nExamples:\n
:header remote:%@ status:%s size:%i
:header
\nOr, adding <header ...> in markup form to your prompt:\n
export PS1=\"<header \\$(git rev-parse --abbrev-ref HEAD)>\\\\n$PS1\"
export PS1=\"<header \\$(git status -s 2>/dev/null|paste -sd \\\" \\\" - )>\\\\n$PS1\""
(setq header-line-format (and (not (string-empty-p header)) header)))
(defun shx-cmd-help (shx-command)
"(SAFE) Display help on the SHX-COMMAND.
If function doesn't exist (or none is supplied), read from user."
(shx--asynch-funcall #'shx-describe-command (list shx-command)))
(defalias 'shx-cmd-h #'shx-cmd-help)
(defun shx-cmd-keep (_arg)
"(SAFE) Add the previous command into `shx-kept-commands'.
This enables it to be accessed later using `shx-cmd-kept'."
(let* ((command (substring-no-properties (ring-ref comint-input-ring 1)))
(desc (read-string (format "'%s'\nDescription: " command))))
(if (string-empty-p desc)
(shx-insert 'error "Description is required" "\n")
(add-to-list 'shx-kept-commands `(,desc . ,command))
(customize-save-variable 'shx-kept-commands shx-kept-commands)
(shx-insert "Keeping as " 'font-lock-doc-face desc "\n")
(shx--hint "type ':kept' or ':k' to see all kept commands"))))
(defun shx-cmd-kept (regexp)
"(SAFE) List the \"kept\" commands that match REGEXP.
Each matching command is appended to the input history, enabling
access via \\[comint-previous-input] and \\[comint-next-input].\n
The list containing all of these commands is `shx-kept-commands'.
That list can be added to using `shx-cmd-keep'."
(cond
((string-empty-p regexp)
(shx--reveal-kept-commands ".*" nil)
(shx--hint "M-x customize-variable shx-kept-commands edits this list"))
(t
(shx--reveal-kept-commands regexp t)
(shx--hint "Commands have been appended to session history"))))
(defalias 'shx-cmd-k #'shx-cmd-kept)
(defun shx-cmd-man (topic)
"Launch an Emacs `man' window for TOPIC.
See `Man-notify-method' for what happens when the page is ready."
(man topic))
(defun shx-cmd-name (name)
"(SAFE) Rename the current buffer to NAME."
(rename-buffer (generate-new-buffer-name name)))
(defun shx-cmd-oedit (file)
"(SAFE) open FILE in other window.
\nExamples:\n
:oedit directory/to/file
:oedit /username@server:~/directory/to/file"
(setq file (car (shx-tokenize-filenames file)))
(if (or (string= "" file) (not file))
(shx-insert 'error "Couldn't parse filename" "\n")
(shx-insert "Editing " 'font-lock-doc-face file "\n")
(find-file-other-window (expand-file-name file))))
(defun shx-cmd-pwd (_args)
"(SAFE) Show what Emacs thinks the default directory is.
\nNote if you're at a shell prompt, you can probably use
\\[shell-resync-dirs] to reset Emacs' pwd to the shell's pwd."
(shx-insert default-directory "\n"))
(defun shx-cmd-ssh (host)
"Open a shell on HOST using tramp.
\nThis way you benefit from the remote host's completions, and
commands like :pwd and :edit will work correctly. Use :ssh on
its own to point the process back at the local filesystem.
\nExample:\n
:ssh username@hostname:port
:ssh"
(let ((host (substring-no-properties (replace-regexp-in-string ":" "#" host))))
(shx--restart-shell
(cond
((string= "" host)
(getenv "HOME"))
((eq tramp-syntax 'default)
(format "/ssh:%s:~" host))
(t (concat "/" host "~:"))))))
(defun shx-cmd-docker (container-id)
"Open a shell in a Docker container with CONTAINER-ID."
(if (and (version< emacs-version "29") (not (require 'docker-tramp nil t)))
(shx-insert 'error "Install the 'docker-tramp' package first\n")
(let ((host
(substring-no-properties
(replace-regexp-in-string ":" "#" container-id))))
(shx--restart-shell
(cond
((string= "" host)
(getenv "HOME"))
((eq tramp-syntax 'default)
(format "/docker:%s:~" host))
(t (concat "/" host "~:")))))))
(defun shx-cmd-sedit (file)
"Open local FILE using sudo (i.e. as super-user).
\nExample:\n
:sedit /etc/passwd"
(shx-cmd-edit (concat "/sudo::" (expand-file-name file))))
;;; graphical user commands
(defun shx-cmd-plotbar (filename)
"(SAFE) Show barplot of FILENAME.
\nFor example, \":plotbar file.dat\" where file.dat contains:\n
\"Topic 1\" YHEIGHT1
\"Topic 2\" YHEIGHT2
\"Topic 3\" YHEIGHT3"
(shx-insert-plot
(car (shx-tokenize-filenames filename))
(concat "set boxwidth 1.5 relative;"
"set style data histograms;"
"set xtic rotate by -40 scale 0 font \",10\";"
"set yrange [0:];"
"set style fill solid 1.0 border -1;"
"plot")
"u 2:xticlabels(1)"))
(defun shx-cmd-plotmatrix (filename)
"(SAFE) Show heatmap of FILENAME.
\nFor example, \":plotmatrix file.dat\" where file.dat contains:\n
1.5 2 3\n 4 5 6\n 7 8 9.5"
(shx-insert-plot
(car (shx-tokenize-filenames filename))
(concat "set view map; unset xtics; unset ytics;"
"unset title; set colorbox; set palette defined"
"(0 \"#ffffff\", 1 \"#d5e585\", 2 \"#8cc555\","
"3 \"#55a550\", 4 \"#1e5500\");"
"plot")
"u 1:(-$2):3 matrix w image"))
(defun shx-cmd-plotline (filename)
"(SAFE) Show line plot of FILENAME.
\nFor example, \":plotscatter file.dat\", where file.dat contains:
1 2\n 2 4\n 4 8\n
Or just a single column:
1\n 2\n 3\n 5"
(shx-insert-plot (car (shx-tokenize-filenames filename)) "plot" "w l lw 1"))