@@ -0,0 +1,485 @@
;;; syd-lisp-lib.el -*- lexical-binding: t; -*-
( require 'general )
( require 'clj-lib )
( use-package smartparens
:defer t )
( use-package evil-surround
:defer t )
;; Include various lispy symbols as word constituents.
( dolist ( c ' ( ?- ?_ ?? ?! ?+ ?* ?/ ?: ?> ?< ?= ?& ) )
( modify-syntax-entry c " w " lisp-data-mode-syntax-table ) )
;;;###autoload
( defvar-keymap syd-lisp-mode-map
:doc " Keymap for `syd-lisp-mode' . " )
;;;###autoload
( define-minor-mode syd-lisp-mode
" A minor mode for editing lispy languages. "
:keymap syd-lisp-mode-map )
;;;###autoload
( defun syd-wrap-sexp ( char )
" Wrap the sexp at point (using `smartparens' ) with the pair corresponding to
CHAR (using `evil-surround' ). Unlike other `evil-surround' operations, the
point will be preserved and the wrapped region will be re-indented. "
( interactive ( evil-surround-input-char ) )
( sp-get ( sp-get-thing )
( save-excursion
( evil-surround-region :beg :end 'inclusive char )
( indent-region :beg :end ) ) ) )
;;;###autoload
( evil-define-motion syd-get-enclosing-sexp ( )
" Like `sp-get-enclosing-sexp' , but with a slightly different meaning of
\" enclosing sexp \" that matches Vim-sexp's "
( or ( let ( ( sexp-at-point ( sp-get-sexp ) ) )
( sp-get sexp-at-point
( when ( or ( and :beg ( = ( point ) :beg ) )
( and :end ( = ( point ) ( - :end 1 ) ) ) )
sexp-at-point ) ) )
( let ( ( sp-enclosing-sexp ( sp-get-enclosing-sexp ) ) )
( sp-get sp-enclosing-sexp
( when :beg
sp-enclosing-sexp ) ) ) ) )
;;;###autoload
( evil-define-motion syd-backward-up-sexp ( count )
" Move point to the opening bracket of the enclosing sexp. The precise meaning
of \" enclosing sexp \" differs slightly from that used by Smartparens for the
sake of a more Vim-like feel inspired by vim-sexp. "
:type exclusive
( dotimes ( _ ( or count 1 ) )
;; REVIEW: Is there a better way to do this? I'm slightly uncomfortable
;; calling two different `sp-get-*' functions.
( or ( sp-get ( sp-get-sexp )
( when ( and :end ( = ( point ) ( - :end 1 ) ) )
( goto-char :beg ) ) )
( sp-get ( sp-get-enclosing-sexp )
( when :beg
( goto-char :beg ) ) ) ) ) )
;;;###autoload
( evil-define-motion syd-forward-up-sexp ( &optional count )
" Move point to the closing bracket of the enclosing sexp. See
`syd-backward-up-sexp' . "
:type exclusive
( dotimes ( _ ( or count 1 ) )
( or ( sp-get ( sp-get-sexp )
( when ( and :beg ( = ( point ) :beg ) )
( goto-char ( - :end 1 ) ) ) )
( sp-get ( sp-get-enclosing-sexp )
( when :end
( if ( = ( point ) ( - :end 1 ) )
( sp-get ( save-excursion ( forward-char )
( sp-get-enclosing-sexp ) )
( when :end
( goto-char ( - :end 1 ) ) ) )
( goto-char ( - :end 1 ) ) ) ) ) ) ) )
;;;###autoload
( defun syd-get-top-level-sexp ( )
" Get the top-level sexp enclosing point. Destructure with `sp-get' .' "
;; The end position returned by `bounds-of-thing-at-point' includes an
;; unpredictable amount of trailing whitespace, so we discard it and compute
;; our own figure.
( let ( ( original-point ( point ) ) )
( -when-let ( ( beg . _ ) ( bounds-of-thing-at-point 'defun ) )
( save-excursion
( goto-char beg )
;; We can trust Smarparents to get the desired end position.
( -let* ( ( top-level-sexp ( sp-get-sexp ) )
( ( _ . end ) ( sp-get top-level-sexp ( cons :beg :end ) ) ) )
;; If the sexp is behind point, we aren't interested in it; find one
;; /ahead/ of point.
( if ( < original-point end )
top-level-sexp
( goto-char end )
( sp-next-sexp )
( sp-get-sexp ) ) ) ) ) ) )
;;;###autoload
( defun syd-get-top-level-sexp-and-attached-comment-bounds ( )
" Get the bounds of top-level sexp enclosing point and the \" attached \"
comment, if there is one. Returns nil or a pair (BEG . END). "
( -when-let ( ( beg . end ) ( sp-get ( syd-get-top-level-sexp ) ( cons :beg :end ) ) )
( let ( ( attached-comment-beg ( save-excursion
( goto-char beg )
( syd-sexp--backward-attached-comment ) ) ) )
( cons ( or attached-comment-beg beg )
end ) ) ) )
( evil-define-motion syd-forward-defun ( count )
:jump t
( sp-get ( syd-get-top-level-sexp )
( goto-char :beg )
( dotimes ( _ ( or count 1 ) )
( sp-next-sexp ) ) ) )
( defvar syd-sexp-cleanup-operators ' ( evil-delete )
" When `syd-evil-a-defun' is used in combination with one of these operators,
some cleanup will be performed. " )
( defun syd-sexp--backward-attached-comment ( )
" Assuming point is on the opening delimiter of a sexp, move point backward to
the beginning of the \" attached \" comment. "
( let ( ( sexp-line ( line-number-at-pos ) )
( sexp-column ( current-column ) ) )
( -when-let ( ( beg . _end ) ( save-excursion
( goto-line ( - sexp-line 1 ) )
( evil-forward-char sexp-column t )
( sp-get-comment-bounds ) ) )
( goto-char beg ) ) ) )
;;;###autoload
( evil-define-text-object syd-evil-a-defun ( count _beg _end _type )
" Selects the enclosing top-level sexp. With a COUNT of N, that many
consequtive top-level sexps will be selected. TODO: Special care will be taken
to clean up whitespace following certain operators. "
:type inclusive
( when ( < count 0 )
( user-error " TODO: Negative count " ) )
( -let ( ( cleanup-p ( memq evil-this-operator syd-sexp-cleanup-operators ) )
( ( beg-0 . end-0 )
( syd-get-top-level-sexp-and-attached-comment-bounds ) ) )
( if ( or ( null count ) ( = count 1 ) )
( list beg-0 end-0 )
( goto-char end-0 )
( dotimes ( _ ( - count 1 ) )
( sp-next-sexp ) )
( sp-get ( sp-get-sexp )
( list beg-0 :end ) ) ) ) )
;; IDEA: How about the inner-defun text object selects the defun /without/ the
;; comment? Is that more useful, or less? I can't think of the last time I've
;; needed the top-level sexp without the brackets.
;;;###autoload
( evil-define-text-object syd-evil-inner-defun ( _count _beg _end _type )
" Select the *content* of the enclosing top-level sexp, i.e. without the
delimiters. "
:type inclusive
( sp-get ( syd-get-top-level-sexp )
( list ( + :beg 1 )
( - :end 1 ) ) ) )
( defun syd-sexp--forward-trailing-whitespace ( sexp )
" Move point to the end of the whitespace trailing after SEXP. "
( goto-char ( sp-get sexp :end ) )
( skip-chars-forward " [:blank:] " )
( when ( = ( char-after ) ?\n )
( forward-char )
( skip-chars-forward " [:blank:] " ) ) )
( defun syd-sexp--backward-leading-whitespace ( sexp )
" Move point to the beginning of the whitespace preceding SEXP. "
( goto-char ( sp-get sexp :beg ) )
( skip-chars-backward " [:blank:] " )
( when ( = ( char-before ) ?\n )
( backward-char )
( skip-chars-backward " [:blank:] " ) ) )
;;;###autoload
( evil-define-text-object syd-evil-a-form ( count _beg _end _type )
( let* ( ( cleanup-p ( memq evil-this-operator syd-sexp-cleanup-operators ) )
( sexp ( syd-get-enclosing-sexp ) ) )
( if cleanup-p
( save-excursion
( if ( syd-sexp--looking-at-last-p )
( progn ( syd-sexp--backward-leading-whitespace sexp )
( list ( point ) ( sp-get sexp :end ) ) )
( syd-sexp--forward-trailing-whitespace sexp )
( list ( sp-get sexp :beg ) ( point ) ) ) )
( sp-get sexp ( list :beg :end ) ) ) ) )
;;;###autoload
( evil-define-text-object syd-evil-inner-form ( count _beg _end _type )
( sp-get ( syd-get-enclosing-sexp )
( list ( + :beg 1 ) ( - :end 1 ) ) ) )
;;;###autoload
( evil-define-command syd-open-sexp-below ( )
" Insert a newline with appropriate indentation after the enclosing sexp. A
sexp-wise analogue to Evil's line-wise `evil-open-below' . "
:suppress-operator t
( evil-with-single-undo
;; We want to add an additional blank line when operating at the top level.
;; Instead of parsing upward until we can no longer find an enclosing sexp, we
;; simply check if the opening bracket is on the first column. This is not
;; very correct, but it's way less work (for myself and the CPU). If we
;; switch to a tree-sitter– based parser, I'd love to switch to the correct
;; algorithm.
( -let* ( ( ( beg . end ) ( sp-get ( syd-get-enclosing-sexp ) ( cons :beg :end ) ) )
( col ( save-excursion ( goto-char beg ) ( current-column ) ) ) )
( goto-char end )
( if ( = col 0 )
( newline 2 )
( newline-and-indent ) ) ) )
( evil-insert-state 1 ) )
;;;###autoload
( evil-define-command syd-open-sexp-above ( )
" Insert a newline with appropriate indentation above the enclosing sexp. A
sexp-wise analogue to Evil's line-wise `evil-open-above' . "
:suppress-operator t
( evil-with-single-undo
( let ( ( beg ( sp-get ( syd-get-enclosing-sexp ) :beg ) ) )
( goto-char beg )
( syd-sexp--backward-attached-comment )
( let ( ( col ( current-column ) ) )
( save-excursion
;; We want to add an additional blank line when operating at the top
;; level. Instead of parsing upward until we can no longer find an
;; enclosing sexp, we simply check if the opening bracket is on the
;; first column. This is not very correct, but it's way less work (for
;; myself and the CPU). If we switch to a tree-sitter– based parser, I'd
;; love to switch to the correct algorithm.
( if ( = col 0 )
( newline 2 )
( newline-and-indent ) ) )
( indent-to col )
( evil-insert-state 1 ) ) ) ) )
( defun syd-sexp-get-last-thing ( )
( -let ( ( ( enclosing-beg . enclosing-end )
( sp-get ( syd-get-enclosing-sexp ) ( cons :beg :end ) ) ) )
( save-excursion
;; Imperative andy. }:\
( let ( thing )
( while ( sp-get ( syd-get-thing )
( and ( < enclosing-beg :beg enclosing-end )
( < enclosing-beg :end enclosing-end ) ) ) ) ) ) ) )
( defun syd-sexp--looking-at-last-p ( )
" Return non-nil if the sexp beginning at point is the last element of its
enclosing sexp. "
( save-excursion
( let ( ( point-0 ( point ) )
( sexp ( sp-get-enclosing-sexp ) ) )
( sp-next-sexp )
( if sexp
( or
;; If `sp-next-sexp' moved backwards, `point-0' was the last
;; element.
( <= ( point ) point-0 )
;; If `sp-next-sexp' moved outside of the previously-enclosing
;; sexp, `point-0' was final.
( <= ( sp-get sexp :end ) ( point ) ) )
;; No enclosing sexp — we're looking at a top-level sexp.
( = ( point ) point-0 ) ) ) ) )
( defun syd-sexp--next-thing ( )
" Helper for `syd-sexo->' . Find the next thing relative to the sexp assumed to
begin at point, and the region covering the closing delimiters. "
( save-excursion
( condition-case err
( cl-loop for relative-height from 0
while ( syd-sexp--looking-at-last-p )
do ( or ( sp-backward-up-sexp )
;; Nothing to slurp!
( signal 'top ) )
finally return ( cons ( sp-next-sexp ) relative-height ) )
( top nil ) ) ) )
( defun syd-sexp--slurp-forward ( )
" Slurp forward. Do not call this function directly; see `syd-sexp->' . "
;; REVIEW: This is rather unoptimised when used with a count.
( when-let* ( ( consumer ( sp-get-sexp ) ) )
( goto-char ( sp-get consumer :beg ) )
( -if-let ( ( next-thing . relative-height ) ( syd-sexp--next-thing ) )
( progn ( goto-char ( sp-get consumer :beg-in ) )
( sp-forward-slurp-sexp ( + 1 relative-height ) )
( sp-get ( sp-get-enclosing-sexp )
( goto-char ( - :end 1 ) ) ) )
( user-error " ra " ) ) ) )
( defun syd-sexp--barf-forward ( )
" Barf forward. Do not call this function directly; see `syd-sexp-<' . "
( sp-forward-barf-sexp ) )
;;;###autoload
( evil-define-command syd-sexp-> ( &optional count )
( interactive " <c> " )
( evil-with-single-undo
( when-let* ( ( sexp ( sp-get-sexp ) ) )
( let ( ( fn ( cond ( ( = ( point ) ( sp-get sexp ( - :end 1 ) ) )
#' syd-sexp--slurp-forward ) ) ) )
( dotimes ( _ ( or count 1 ) )
( funcall fn ) ) ) ) ) )
;;;###autoload
( evil-define-command syd-sexp-< ( &optional count )
( interactive " <c> " )
( evil-with-single-undo
( when-let* ( ( sexp ( sp-get-sexp ) ) )
( let ( ( fn ( cond ( ( = ( point ) ( sp-get sexp ( - :end 1 ) ) )
#' syd-sexp--barf-forward ) ) ) )
( dotimes ( _ ( or count 1 ) )
( funcall fn ) ) ) ) ) )
( defun syd-sexp--looking-at-delimiter-p ( )
( sp-get ( sp-get-sexp )
( and ( not ( sp-point-in-string-or-comment ) )
( or ( = ( point ) :beg )
( = ( point ) ( - :end 1 ) ) ) ) ) )
;; REVIEW: It might be neat to, iff the point is already in a comment/string,
;; goto delimiters that are also in comments/strings. For now, let's just
;; ignore comments.
( defun syd-sexp--goto-delimiter ( delimiter-type direction count )
( let* ( ( point-0 ( point ) )
( delimiters ( mapcar ( clj-condp eq delimiter-type
( 'opening #' car )
( 'closing #' cdr ) )
sp-pair-list ) )
( delimiter-regexp ( rx-to-string ` ( or ,@ delimiters ) ) )
( forward-p ( clj-condp eq direction
( 'forward t )
( 'backward nil )
( t ( error " todo errrrare " ) ) ) )
( move ( lambda ( )
;; `forward-p' never changes between calls to `move'; we are
;; doing many more checks than we need to.
( and ( condition-case er
( prog1 t ( when forward-p
( forward-char ) ) )
( end-of-buffer ( throw 'no-move 'no-move ) ) )
( if ( if forward-p
( re-search-forward delimiter-regexp nil t )
( re-search-backward delimiter-regexp nil t ) )
( goto-char ( match-beginning 0 ) )
( throw 'no-move 'no-move ) ) ) ) ) )
;; If `syd-sexp--looking-at-delimiter-p' returns nil, we may be looking at
;; the right string of characters, but we are likely inside of a string,
;; or a comment, or something. If we aren't at a "real" delimiter, move
;; again.
( let ( ( r ( catch 'no-move
( dotimes ( _ count )
( while ( and ( funcall move )
( not ( syd-sexp--looking-at-delimiter-p ) ) ) ) ) ) ) )
( if ( eq r 'no-move )
( progn ( goto-char point-0 )
( user-error " Nowhere to go " ) )
r ) ) ) )
( evil-define-motion syd-sexp-forward-opening ( count )
( syd-sexp--goto-delimiter 'opening 'forward ( or count 1 ) ) )
( evil-define-motion syd-sexp-backward-opening ( count )
( syd-sexp--goto-delimiter 'opening 'backward ( or count 1 ) ) )
( evil-define-motion syd-sexp-forward-closing ( count )
( syd-sexp--goto-delimiter 'closing 'forward ( or count 1 ) ) )
( evil-define-motion syd-sexp-backward-closing ( count )
( syd-sexp--goto-delimiter 'closing 'backward ( or count 1 ) ) )
( defun syd-sexp-get-sexp-with-prefix ( )
( -when-let* ( ( thing ( sp-get-thing ) )
;; TODO: Rewrite using :beg-prf
( ( beg . prefix ) ( sp-get thing ( cons :beg :prefix ) ) )
( prefix-beg ( - beg ( length prefix ) ) ) )
;; HACK: Relies on Smartparen's internal representation, which
;; they explicitly recommend against. This could break at any
;; time!
;; Reminder that `plist-put' is an in-place update. }:)
( plist-put thing :beg prefix-beg )
( plist-put thing :prefix " " )
( goto-char prefix-beg )
thing ) )
( evil-define-motion syd-sexp-next ( count )
" Like `sp-next-sexp' , but prefixes will be considered as part of the sexp. "
;; If point is resting on a prefix when `syd-sexp-next' is called,
;; `sp-next-sexp' will move to the beginning of the prefixed form. This is
;; undesirable, as `syd-sexp-next' considers the prefix and the prefixed form
;; to be a single thing. To get around this, we make sure to move point past
;; the prefixed sexp.
( let ( ( count* ( or count 1 ) ) )
( when-let* ( ( _ ( <= 0 count* ) )
( first-prefixed-sexp ( syd-sexp-get-sexp-with-prefix ) ) )
( sp-get first-prefixed-sexp
( when ( <= :beg ( point ) :end )
( goto-char :end ) ) ) )
( let ( ( current-prefix-arg count* ) )
( call-interactively #' sp-next-sexp ) ) )
( syd-sexp-get-sexp-with-prefix ) )
( evil-define-motion syd-sexp-previous ( count )
" Like `sp-next-sexp' (as if called with a negative count), but prefixes will
be considered as part of the sexp. "
( syd-sexp-next ( - ( or count 1 ) ) ) )
;;;###autoload
( evil-define-command syd-sexp-insert ( )
( evil-with-single-undo
( sp-get ( syd-get-enclosing-sexp )
( goto-char ( + 1 :beg ) )
( save-excursion ( insert-char ?\s ) )
( evil-insert-state 1 ) ) ) )
;;;###autoload
( evil-define-command syd-sexp-append ( )
( evil-with-single-undo
( sp-get ( syd-get-enclosing-sexp )
( goto-char ( - :end 1 ) )
( evil-insert-state 1 ) ) ) )
;; Text objects.
( general-def
:keymaps 'syd-lisp-mode-map
:states ' ( visual operator )
" ad " #' syd-evil-a-defun
" id " #' syd-evil-inner-defun
" af " #' syd-evil-a-form
" if " #' syd-evil-inner-form )
;; Bind editing commands in normal node, and motion commands in motion
;; mode.
( general-def
:keymaps 'syd-lisp-mode-map
:states 'normal
" > " #' syd-sexp->
" < " #' syd-sexp-<
" M-w " #' syd-wrap-sexp
" M-r " #' sp-raise-sexp
" M-c " #' sp-clone-sexp
" M-S " #' sp-split-sexp
" M-J " #' sp-join-sexp
" M-u " #' sp-splice-sexp-killing-backward
" M-U " #' sp-splice-sexp-killing-around
" M-v " #' sp-convolute-sexp
" M-o " #' syd-open-sexp-below
" M-O " #' syd-open-sexp-above
" M-i " #' syd-sexp-insert
" M-a " #' syd-sexp-append )
;; Bind editing commands in normal node, and motion commands in motion
;; mode.
( general-def
:keymaps 'syd-lisp-mode-map
:states 'motion
" C-h " #' sp-backward-up-sexp
" C-j " #' syd-sexp-next
" C-k " #' syd-sexp-previous
" C-l " #' sp-down-sexp
" ( " #' syd-backward-up-sexp
" ) " #' syd-forward-up-sexp
" { " #' syd-sexp-backward-opening
" } " #' syd-sexp-forward-opening
" M-{ " #' syd-sexp-backward-closing
" M-} " #' syd-sexp-forward-closing )
( with-eval-after-load 'smartparens
( setq
;; By default, Smartparens will move backwards to the initial character of
;; the enclosing expression, and only move forwards when the point is already
;; on that initial character. This is not expected behaviour for an ex-Vim
;; user.
sp-navigate-interactive-always-progress-point t ) )
( provide 'syd-lisp-lib )