Emacs, scripting and anything text oriented.

Accessing Devdocs from Emacs

Kaushal Modi

Spoiled by being able to access in-built docs in Emacs at fingertips, here’s an attempt to kind-of do that for Nim documentation too, using devdocs.io.

<2021-12-17>
‼️ This post is outdated. The index.json link from devdocs.io referenced later in the post does not exist any more.

Nim lang has good documentation for all its stdlib functions online. But the Emacs user in me does not like to switch back and forth between the Nim code in Emacs buffers and the docs outside in an external browser.

Well.. a solution to that, that this post is about, still needs one to look up the Nim docs in an external browser.. but the workflow is a bit better — You don’t need to manually launch the doc site, and you don’t need to then manually type in the search query.

If you want to skip the history and code analysis, you can directly jump to the Demo or the Final Code.

JSON Docs #

I tried asking folks on r/nim if there was a good solution for in-editor Nim doc access. /u/PMunch from Reddit gave a wonderful solution—To generate JSON docs for the Nim stdlib, and then parse those to display the docs within Emacs.

    I would love that solution!

.. just that I don’t know how to get a nice single .json for the whole of Nim documentation.

If someone knows how to do that, please let me know.

Devdocs.io #

So I continued my search online.. I was looking if someone had already implemented a way to access Nim docs from the command line, and that somehow led me to https://devdocs.io/nim/!

And after searching further for “devdocs Emacs”, I found these two Emacs packages:

Christopher Wellon’s devdocs-lookup #

After reviewing the two packages, I decided to build my solution further upon the devdocs-lookup package1 by Christopher Wellons aka @skeeto from GitHub.

Here’s why —

  1. He first fetches the whole JSON search index (line 5) from docs.devdocs.io for the picked “subject” (which would be “Nim” for this post). So the search index for Nim documentation would be at https://docs.devdocs.io/nim/index.json2.

    The retrieved JSON is then parsed using json-read (line 13).

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    (defun devdocs-index (subject &optional callback)
      "Return the devdocs.io index for SUBJECT, optionally async via CALLBACK."
      (cl-declare (special url-http-end-of-headers))
      (let ((index (gethash subject devdocs-index))
            (url (format "%s/%s/index.json" devdocs-base-index-url subject)))
        (cond ((and index callback)
               (funcall callback index))
              ((and index (not callback))
               index)
              ((and (not index) (not callback))
               (with-current-buffer (url-retrieve-synchronously url nil t)
                 (goto-char url-http-end-of-headers)
                 (setf (gethash subject devdocs-index) (json-read))))
              ((and (not index) callback)
               (url-retrieve
                url
                (lambda (_)
                  (goto-char url-http-end-of-headers)
                  (setf (gethash subject devdocs-index) (json-read))
                  (funcall callback (gethash subject devdocs-index))))))))
    
    Code Snippet 1: Function to fetch the search index from devdocs.io and parse the JSON
  2. The name and path properties from the parsed JSON are then stored in an association list on lines 4 and 5.

    1
    2
    3
    4
    5
    
    (defun devdocs-entries (subject)
      "Return an association list of the entries in SUBJECT."
      (cl-loop for entry across (cdr (assoc 'entries (devdocs-index subject)))
               collect (cons (cdr (assoc 'name entry))
                             (cdr (assoc 'path entry)))))
    
    Code Snippet 2: Function to store the name and path properties to alists

    So a JSON entry like this:

    {
        "name": "os.walkDirRec",
        "path": "os#walkDirRec.i,string",
        "type": "os"
    }
    

    would translate to this Emacs-Lisp alist element:

    ("os.walkDirRec" . "os#walkDirRec.i,string")
    
  3. Then the list of all the car’s of such elements is used to create a collection of entries for completion (line 3).

    1
    2
    3
    4
    5
    6
    7
    
    (defun devdocs-read-entry (subject)
      "Interactively ask the user for an entry in SUBJECT."
      (let ((names (mapcar #'car (devdocs-entries subject)))
            (hist (intern (format "devdocs--hist-%s" subject))))
        (unless (boundp hist)
          (set hist nil))
        (completing-read "Entry: " names nil :match nil hist)))
    
    Code Snippet 3: Function to show completion list based on "names" from the JSON-parsed database
  4. And finally, for the selected name, the associated path is retrieved from that alist (line 7), and we browse to that path using the Emacs browse-url function (line 9). User can of course configure the browser to be used when that function is called.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    (defun devdocs-lookup (subject entry)
      "Visit the documentation for ENTRY from SUBJECT in a browser."
      (interactive
       (let* ((subject (devdocs-read-subject))
              (entry (devdocs-read-entry subject)))
         (list subject entry)))
      (let ((path (cdr (assoc entry (devdocs-entries subject)))))
        (when path
          (browse-url (format "%s/%s/%s" devdocs-base-url subject path))
          :found)))
    
    Code Snippet 4: Function to browse the doc page associated with the user-selected "name"

All of that worked beautifully. As I used it a few times though, I felt a need to add a touch of DWIM to that.

Making devdocs-lookup DWIM #

Here are the 2 things that I wanted to happen automatically:

Auto-select subject based on major-mode if possible #

In the original code, if I used devdocs-lookup function, I needed to manually select the “Nim” subject even when I called that function from a nim-mode buffer. At least for my use cases, I would want to access only Nim docs if I am looking up devdocs while in a Nim code buffer.

The package has an interesting function called devdocs-setup which would generate a function specific to each subject.. so for “Nim” subject, it would generate a devdocs-lookup-nim function.

But I wanted to avoid calling devdocs-setup too.

So below is what I did:

(defun devdocs-lookup (subject entry)
  "Visit the documentation for ENTRY from SUBJECT in a browser."
  (interactive
   (cl-letf (((symbol-function 'string-match-case-insensitive)
              (lambda (str1 str2)
                (string= (downcase str1) (downcase str2)))))
     (let* ((major-mode-str (replace-regexp-in-string "-mode" "" (symbol-name major-mode)))
            ;; If major mode is `nim-mode', the ("Nim" "nim") element
            ;; will be auto-picked from `devdocs-subjects'.
            (subject-dwim (cadr (assoc major-mode-str devdocs-subjects
                                       #'string-match-case-insensitive)))
            (subject (or subject-dwim (devdocs-read-subject)))
            (entry (devdocs-read-entry subject)))
       (list subject entry))))
  (let ((path (cdr (assoc entry (devdocs-entries subject)))))
    (when path
      (browse-url (format "%s/%s/%s" devdocs-base-url subject path))
      :found)))
Code Snippet 5: Modification of the original devdocs-lookup – Now auto-selects the subject if the major-mode matches.

Auto-filter using the symbol at point #

The second thing that I wanted to work upon was to make Emacs kind of “know” what I was trying to search.

Originally, devdocs-read-entry would always show the completion-list pointing at the first entry. I wanted to make that a bit more intelligent.. If my point were on the walkDirRec proc identifier on a line like below,

for filepath in walkDirRec(appPath, yieldFilter={pcFile}):

I wanted the collection to narrow down to only the entries that matched “walkDirRec”.

Below is my modification to do that.

(defun devdocs-read-entry (subject)
  "Interactively ask the user for an entry in SUBJECT."
  (let ((names (mapcar #'car (devdocs-entries subject)))
        (hist (intern (format "devdocs--hist-%s" subject)))
        (init (symbol-name (symbol-at-point))))
    (unless (boundp hist)
      (set hist nil))
    (completing-read (format "Entry (%s): " subject) names nil :require-match init hist)))
Code Snippet 6: Modification of the original devdocs-read-entry – Now auto-filters the entries that match the symbol at point.

Above function just pre-sets the filter.. If the user wants to change the search string, they can still do that.

Using devdocs-lookup #

Finally, I like the key-chord.el package. So using that, I bind the ?? key-chord to the modified devdocs-lookup function.

So if I want to look up the docs for walkDirRec on that line in the above example, I just move the point there, and hit ??, and the docs for that will pop up in my browser..

  • No manual launching of the browser.
  • No manual typing of the search string.

Demo #

It won’t be fun if I did not end this post without a demo. So here it is —

Figure 1: Click the above image to see the devdocs.io access from Emacs in action (GIF)

Figure 1: Click the above image to see the devdocs.io access from Emacs in action (GIF)

Code #

You can find the modified devdocs-lookup code here.


  1. All of the code snippets from Christopher Wellon’s devdocs-lookup that now follow in this post are from this commit↩︎

  2. If you visit that file in Firefox, it will show up in a wonderful formatted form with collapsible drawers, search, etc. ↩︎