Monday, December 21, 2009

Emacs: Named Desktop Sessions

Emacs:  Named Desktop Sessions

You can use the desktop package to save the state of Emacs sessions, i.e. which files are open, where point is in them and so on.  This is cool, but it saves the information in the current directory, which can be inconvenient.  What would be better is if you could save the session by name, like "task-a" or "project-b".

I originally wrote some elisp to do this in answer to a StackOverflow question, but found it so useful myself that I expanded it to do more.  The code is below, but here are the commands:
  • my-desktop-save -- Save the current session by name
  • my-desktop-save-and-clear -- Same as above, but clear out all the buffers so you start with a "clean" session
  • my-desktop-read -- Load a session by name
  • my-desktop-change -- Save the current session and load a different one
  • my-desktop-name -- Echo the current session name
One more thing this code does: when you exit Emacs it automatically saves the current state as "last-session".  Sometimes I fat finger "C-x C-c" and exit accidentally, or I need to restart Emacs or my window manager and this lets me get back to what I was doing quickly.

The Code

(require 'desktop)

(defvar my-desktop-session-dir
  (concat (getenv "HOME") "/.emacs.d/desktop-sessions/")
  "*Directory to save desktop sessions in")

(defvar my-desktop-session-name-hist nil
  "Desktop session name history")

(defun my-desktop-save (&optional name)
  "Save desktop by name."
  (interactive)
  (unless name
    (setq name (my-desktop-get-session-name "Save session" t)))
  (when name
    (make-directory (concat my-desktop-session-dir name) t)
    (desktop-save (concat my-desktop-session-dir name) t)))

(defun my-desktop-save-and-clear ()
  "Save and clear desktop."
  (interactive)
  (call-interactively 'my-desktop-save)
  (desktop-clear)
  (setq desktop-dirname nil))

(defun my-desktop-read (&optional name)
  "Read desktop by name."
  (interactive)
  (unless name
    (setq name (my-desktop-get-session-name "Load session")))
  (when name
    (desktop-clear)
    (desktop-read (concat my-desktop-session-dir name))))

(defun my-desktop-change (&optional name)
  "Change desktops by name."
  (interactive)
  (let ((name (my-desktop-get-current-name)))
    (when name
      (my-desktop-save name))
    (call-interactively 'my-desktop-read)))

(defun my-desktop-name ()
  "Return the current desktop name."
  (interactive)
  (let ((name (my-desktop-get-current-name)))
    (if name
        (message (concat "Desktop name: " name))
      (message "No named desktop loaded"))))

(defun my-desktop-get-current-name ()
  "Get the current desktop name."
  (when desktop-dirname
    (let ((dirname (substring desktop-dirname 0 -1)))
      (when (string= (file-name-directory dirname) my-desktop-session-dir)
        (file-name-nondirectory dirname)))))

(defun my-desktop-get-session-name (prompt &optional use-default)
  "Get a session name."
  (let* ((default (and use-default (my-desktop-get-current-name)))
         (full-prompt (concat prompt (if default
                                         (concat " (default " default "): ")
                                       ": "))))
    (completing-read full-prompt (and (file-exists-p my-desktop-session-dir)
                                      (directory-files my-desktop-session-dir))
                     nil nil nil my-desktop-session-name-hist default)))

(defun my-desktop-kill-emacs-hook ()
  "Save desktop before killing emacs."
  (when (file-exists-p (concat my-desktop-session-dir "last-session"))
    (setq desktop-file-modtime
          (nth 5 (file-attributes (desktop-full-file-name (concat my-desktop-session-dir "last-session"))))))
  (my-desktop-save "last-session"))

(add-hook 'kill-emacs-hook 'my-desktop-kill-emacs-hook)

Friday, December 18, 2009

Emacs: Using Bookmarked Directories

Emacs: Using Bookmarked Directories


You can use bookmarks to mark positions in files by name and jump back to them later.  I don't use file bookmarks often, but if you set a bookmark while you're in a dired buffer it saves the directory location.  I work on projects with thousands of files in hundreds of directories so this is extremely useful to me.  I set bookmarks in the dozen or so directories I use all the time, and a few others in strategic directories that I can start from and navigate down.

This post isn't about how to use bookmarks in general, you can find that elsewhere.  You set a bookmark using "C-x r m", and list your bookmarks using "C-x r l".  In the bookmark list you can rename, delete, etc. the bookmarks.

In Dired

Now that we have Ido acting a bit more sensibly, let's use it to choose a bookmark in dired and switch to that directory (I am assuming here you already have Ido enabled).  I like things in most recently used (MRU) order, so when we pick a bookmark we'll also move it to the top of the bookmark list:

(require 'bookmark)

(defun my-ido-bookmark-jump ()
  "Jump to bookmark using ido"
  (interactive)
  (let ((dir (my-ido-get-bookmark-dir)))
    (when dir
      (find-alternate-file dir))))

(defun my-ido-get-bookmark-dir ()
  "Get the directory of a bookmark."
  (let* ((name (ido-completing-read "Use dir of bookmark: " (bookmark-all-names) nil t))
         (bmk (bookmark-get-bookmark name)))
    (when bmk
      (setq bookmark-alist (delete bmk bookmark-alist))
      (push bmk bookmark-alist)
      (let ((filename (bookmark-get-filename bmk)))
        (if (file-directory-p filename)
            filename
          (file-name-directory filename))))))

(defun my-ido-dired-mode-hook ()
  (define-key dired-mode-map "$" 'my-ido-bookmark-jump))

(add-hook 'dired-mode-hook 'my-ido-dired-mode-hook)

Now in dired you press '$' to choose a bookmarked directory to switch to.  I stole '$' away from dired-hide-subdir, which I never use, because it's a mnemonic device as I'll explain later.

In Ido

That's good for dired, but it would be nice to be able to use bookmarks to switch directories when you are doing a regular ido-find-file with "C-x C-f":

(defun my-ido-use-bookmark-dir ()
  "Get directory of bookmark"
  (interactive)
  (let* ((enable-recursive-minibuffers t)
         (dir (my-ido-get-bookmark-dir)))
    (when dir
      (ido-set-current-directory dir)
      (setq ido-exit 'refresh)
      (exit-minibuffer))))

(define-key ido-file-dir-completion-map (kbd "$") 'my-ido-use-bookmark-dir)

Now when you are opening a file, you can type '$', choose a bookmark, then ido will restart the find-file from that location.

It has a quirk that after ido gets reseated in the new directory you can't navigate up, only down.  I get around this by hitting "C-e", editing the path, then "RET" to go back to Ido.  It's somewhat annoying, but it's only a couple keystrokes, and I don't do it often enough to motivate me to try to fix it.

In Your Shell


Now that you're jumping around the file system in Emacs, you'll start to miss it in your terminal.  Let's have Emacs write the bookmarks out to a file as shell variables:

(setq bookmark-save-flag 1)
(setq bookmark-sort-flag nil)

(defadvice bookmark-write-file (after my-bookmark-to-shell activate)
  "Convert bookmarks to format bash and tcsh (yuck!) can use."
  (let (filename)
    (with-temp-buffer
      (dolist (bmk bookmark-alist)
        (if (listp (caadr bmk))
            (setq filename (cdr (assoc 'filename (cadr bmk))))
          (setq filename (cdr (assoc 'filename (cdr bmk)))))
        (unless (file-directory-p filename)
          (setq filename (file-name-directory filename)))
        (insert (car bmk) "=" filename)
        (delete-char -1)
        (newline))
      (write-file "~/.bashrc_bmk")
      (goto-char (point-min))
      (while (not (eobp))
        (beginning-of-line)
        (insert "set ")
        (forward-line))
      (write-file "~/.cshrc_bmk"))))

I have it writing out in both bash and csh compatible formats because I have to use tcsh at work (SIGH).  Now in your .bashrc put:

bmk_file=~/.bashrc_bmk
if [ -f $bmk_file ]; then
  . $bmk_file
fi
alias bmk_reload='. $bmk_file'
alias bmk_list="sort $bmk_file | awk 'BEGIN { FS = "'"[ =]" }; { printf("%-25s%s\n", $1, $2) }'"'"

or in your .cshrc:

set bmk_file=~/.cshrc_bmk
if ( -f $bmk_file ) source $bmk_file
alias bmk_reload "source $bmk_file"
alias bmk_list "sort $bmk_file | awk 'BEGIN { FS = "'"[ =]" }; { printf("%-25s%s\n", $2, $3) }'"'"

Now if you have a bookmark named "proj_a_inc" you can do things like "cd $proj_a_inc" or "cp $proj_a_inc/foo.h ."  This is why I chose "$" above to easily remember my bookmark key (and it usually isn't in a filename).  The bmk_reload command reloads your bookmarks in case you add one in Emacs, and bmk_list lists your bookmarks and the directories they point to.

Wednesday, December 16, 2009

Emacs: Better Ido Flex-Matching

I've had this blog for a long time ... perhaps I should actually post things.  Most will be about Emacs; there are plenty of beginner-level Emacs blogs out there, so I'll lean towards the intermediate/advanced.

Better Ido Flex-Matching

First up: Better Ido flex-matching.  Ido lets you switch buffers, choose files, and so on by presenting you with a list of choices and narrowing the list down as you type.  Flex-matching does "fuzzy" matching of your input to the list of items; i.e. you can just type pieces of what you want, and the pieces don't even need to be next to each other.

This is great, but it has some quirks.  Suppose my list of items was ("chops" "scone" "close" "confide") and I typed "co".  Ido throws out "chops" and "close" -- even though they have 'c' followed by 'o' -- and only presents "scone" and "confide" since they have "co" in them.  Now I add 's' so the input is "cos" and Ido discards the previous, seemingly only, matches and switches to "chops" and "close".  It presents them in that order as well, even though I consider "close" to be a better match since the 'o' and 's' are next to each other.

I found this behavior inconsistent and surprising, so I decided to change the algorithm.  If you want to try my version but aren't interested in how it works, skip the next section.

The Algorithm

The first thing to do is see if someone else has solved this problem.  Trying different searches for "string matching", you get a lot of hits on finding substrings, which isn't what I want, and a lot for Levenshtein Distance.  The Levenshtein algorithm is close (there's even an elisp implementation, and similarly fuzzy-match), but it's more for things like spelling suggestions.  It will always match,  giving you a measure of how close the strings are.  They don't even have to have any of the same characters in them.  There are a couple academic papers also, but they had various problems as well.  I know what I want, I'll just write it myself!

We need to calculate a correlation value between the input and each item; that is, how closely they match.  A correlation value of 'nil' will mean no match, and zero or higher will mean a match with higher values meaning better matches.  The correlation value will be increased by one for every pair of adjacent letters in the input that are adjacent in the item (only the first time counts).  A special case of adding one is if the first letters in both match, as if the beginning of the string could be considered a character.  For example, if the input is "cos" and the item is "close", it will get one point for both starting with 'c', and one point for "os" being adjacent in both for a total of two.  If the item was "chops", it would only get one point (starts with 'c' and has 'o' and 's', but none adjacent).

A hash table is created from the input, with each character being the key and a descending list of the character's positions in the input as the value.  For example, if the input is "aba", the table would have a -> (2 0), b -> (1).  Why they need to be in descending order will be explained later.  Since the same input is used for each item, the hash table is only created once.

For each item, we start with an empty (all nil value) vector with a length equal to that of the input.  We then go through the characters of the item in order, using each to index into the hash table and retrieve the character's corresponding locations in the input string.  We look in the vector at these locations, and if the preceding vector location is non-nil (or we are at the beginning of the vector) we fill in the vector location with a cons cell.  This is why they must be in descending order, otherwise you would incorrectly fill in locations where there were double letters.  The car of the cell is the index of the item where the character was found, and the cdr is the current correlation that we will carry along.  If the car of the preceding vector location (the previous character location in the item) is the current item character location minus one, we add one to the correlation.  When all vector locations have been filled in, we have a match and we save the correlation.  As more matches happen we keep the highest correlation value.

I know I'm kind of skimming over things without a lot of explanation, but this post is already getting too detailed.

The Implementation

Here's the implementation, woven into Ido with a variable to switch to the regular flex-matching if you want:

(defun my-ido-fuzzy-match (str items)
  "Better ido fuzzy matching"
  (let ((str-len (length str)))
    (if (= str-len 0)
        (reverse items)
      (let ((char-lookup (make-hash-table :test 'equal)))
        ;; Make hash table of all characters with their corresponding indexes
        (let ((chars (split-string (if ido-case-fold (downcase str) str) "" t))
              (idx 0)
              elt)
          (dolist (char chars)
            (setq elt (gethash char char-lookup))
            (if elt
                (push idx elt) ;; It's important that the indexes are in descending order
              (setq elt (list idx)))
            (puthash char elt char-lookup)
            (setq idx (1+ idx))))
        ;; Go through all the items
        (let (corr matches)
          (dolist (item items)
            (setq corr (my-ido-match-get-correlation str-len char-lookup (ido-name item)))
            (when corr
              (push (cons item corr) matches)))
          ;; Sort matches and return
          (mapcar 'car (if ido-rotate
                           matches
                         (sort matches (lambda (x y) (> (cdr x) (cdr y)))))))))))

(defun my-ido-match-get-correlation (str-len char-lookup item)
  "Get the correlation for this item"
  (let ((partial-matches (make-vector str-len nil))
        (chars (split-string (if ido-case-fold (downcase item) item) "" t))
        (char-idx 0)
        elt-idxs corr prev-partial-match curr-partial-match)
    (dolist (char chars)
      (setq elt-idxs (gethash char char-lookup))
      (when elt-idxs
        (dolist (elt-idx elt-idxs)
          ;; Current and previous partial matches
          (setq curr-partial-match (aref partial-matches elt-idx))
          (setq prev-partial-match (and (> elt-idx 0)
                                        (aref partial-matches (1- elt-idx))))
          ;; Create a new partial match if necessary
          (when (and (not curr-partial-match)
                     (or prev-partial-match (= elt-idx 0)))
            (setq curr-partial-match
                  (aset partial-matches elt-idx
                        (cons char-idx (if (and (= elt-idx 0) (= char-idx 0)) 1 0)))))
          ;; Set (match-position . correlation)
          (when curr-partial-match
            (setcar curr-partial-match char-idx)
            (when prev-partial-match
              (setcdr curr-partial-match
                      (if (= char-idx (1+ (car prev-partial-match)))
                          (1+ (cdr prev-partial-match))
                        (cdr prev-partial-match))))
            ;; Update final correlation
            (when (= elt-idx (1- str-len))
              (if corr
                  (setq corr (max corr (cdr curr-partial-match)))
                (setq corr (cdr curr-partial-match)))))))
      (setq char-idx (1+ char-idx)))
    corr))

(defvar my-ido-use-fuzzy-match t
  "*Use my-ido-fuzzy-match for ido matching")

(defadvice ido-set-matches-1 (around my-ido-set-matches-1 activate)
  "Choose between the regular ido-set-matches-1 and my-ido-fuzzy-match"
  (if my-ido-use-fuzzy-match
      (setq ad-return-value (my-ido-fuzzy-match ido-text (ad-get-arg 0)))
    ad-do-it))