;;; erldoc.el --- browse Erlang/OTP documentation -*- lexical-binding: t; -*- ;; %CopyrightBegin% ;; ;; Copyright Ericsson AB 2016-2020. All Rights Reserved. ;; ;; Licensed under the Apache License, Version 2.0 (the "License"); ;; you may not use this file except in compliance with the License. ;; You may obtain a copy of the License at ;; ;; http://www.apache.org/licenses/LICENSE-2.0 ;; ;; Unless required by applicable law or agreed to in writing, software ;; distributed under the License is distributed on an "AS IS" BASIS, ;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ;; See the License for the specific language governing permissions and ;; limitations under the License. ;; ;; %CopyrightEnd% ;;; Commentary: ;; Crawl Erlang/OTP HTML documentation and generate lookup tables. ;; ;; This package depends on `cl-lib', `pcase' and ;; `libxml-parse-html-region'. Emacs 24+ compiled with libxml2 should ;; work. On Emacs 24.1 and 24.2 do `M-x package-install RET cl-lib ;; RET' to install `cl-lib'. ;; ;; Please customise `erldoc-man-index' to point to your local OTP ;; documentation. ;; ;; To use: ;; ;; (define-key help-map "u" 'erldoc-browse) ;; (define-key help-map "t" 'erldoc-browse-topic) ;; (define-key help-map "a" 'erldoc-apropos) ;; ;; Note: these commands trigger indexing OTP documentation on first ;; run with cache to disk which may take 1-2 minutes. ;;; Examples: ;; 1. `M-x erldoc-browse RET erlang:integer_to_binary/2 RET' opens the ;; `erlang' manual anchored on the entry for `integer_to_binary/2'. ;; ;; 2. `M-x erldoc-apropos RET first RET' list all MFAs matching ;; substring `first'. ;; ;; 3. `M-x erldoc-browse-topic RET efficiency_guide#Introduction RET' ;; opens chapter `Introduction' of the `Efficiency Guide' in the ;; browser. ;;; History: ;; Written in December 2013 as a temporary solution to help me browse ;; the rich Erlang/OTP documentation. Three years on I find myself ;; still using it every day. - Leo (2016) ;;; Code: (require 'cl-lib) (require 'json) (require 'erlang) (eval-when-compile (require 'url-parse)) (eval-and-compile ;for emacs < 24.3 (or (fboundp 'user-error) (defalias 'user-error 'error))) (defgroup erldoc nil "Browse Erlang document." :group 'help) (defcustom erldoc-man-index "http://www.erlang.org/doc/man_index.html" "The URL to the man_index.html page. Note it is advisable to customise this to a local URL for example `file:///usr/local/19.1/lib/erlang/doc/man_index.html' to speed up the indexing." :type 'string :group 'erldoc) (defcustom erldoc-verify-man-path nil "If non-nil verify man path existence for `file://'." :type 'boolean :group 'erldoc) (defcustom erldoc-output-file (locate-user-emacs-file "cache/erldoc") "File to store the parsed results." :type 'file :group 'erldoc) (defcustom erldoc-no-signature-function #'ignore "Notification function called if no function signature was found." :type '(choice (function-item :tag "Ignore" ignore) (function-item :tag "Warn" warn) (function-item :tag "Error" error)) :group 'erldoc) (defun erldoc-strip-string (s) (let* ((re "[ \t\n\r\f\v\u00a0]+") (from (if (string-match (concat "\\`" re) s) (match-end 0) 0)) (to (and (string-match (concat re "\\'") s) (match-beginning 0)))) (substring s from (and to (max to from))))) ;; Note: don't know how to get the BASE-URL to ;; `libxml-parse-html-region' to work. (defun erldoc-expand-url (url base-url) (if (url-type (url-generic-parse-url url)) url (let* ((base (url-generic-parse-url base-url)) (dir (directory-file-name (file-name-directory (url-filename base))))) (setf (url-filename base) (expand-file-name url dir)) (url-recreate-url base)))) (defun erldoc-parse-html (url) (with-temp-buffer (url-insert-file-contents url) (libxml-parse-html-region (point-min) (point-max)))) (defalias 'erldoc-dom-text-node-p #'stringp) (defun erldoc-dom-attributes (dom) (and (not (erldoc-dom-text-node-p dom)) (cadr dom))) (defun erldoc-dom-get-attribute (dom attrib-name) (cdr (assq attrib-name (erldoc-dom-attributes dom)))) (defun erldoc-dom-children (dom) (and (not (erldoc-dom-text-node-p dom)) (cddr dom))) (defun erldoc-dom-get-text (dom) (let ((text (car (last (erldoc-dom-children dom))))) (and (erldoc-dom-text-node-p text) text))) (defvar erldoc-dom-walk-parent nil) (defvar erldoc-dom-walk-siblings nil) (defun erldoc-dom-walk (dom k) (funcall k dom) (let ((erldoc-dom-walk-parent dom) (erldoc-dom-walk-siblings (unless (erldoc-dom-text-node-p dom) (cddr dom)))) (dolist (child erldoc-dom-walk-siblings) (erldoc-dom-walk child k)))) (defun erldoc-dom-get-element (dom element-name) (catch 'return (erldoc-dom-walk dom (lambda (d) (when (eq (car-safe d) element-name) (throw 'return d)))))) (defun erldoc-dom-get-element-by-id (dom id) (catch 'return (erldoc-dom-walk dom (lambda (d) (when (equal (erldoc-dom-get-attribute d 'id) id) (throw 'return d)))))) (defun erldoc-dom-get-elements-by-id (dom id) (let (result) (erldoc-dom-walk dom (lambda (d) (when (equal (erldoc-dom-get-attribute d 'id) id) (push d result)))) (nreverse result))) (defun erldoc-fix-path (url) (if (and erldoc-verify-man-path ;; Could only verify local files (equal (url-type (url-generic-parse-url url)) "file")) (let* ((obj (url-generic-parse-url url)) (new (car (file-expand-wildcards (replace-regexp-in-string "-[0-9]+\\(?:[.][0-9]+\\)*" "*" (url-filename obj)))))) (or new (error "File %s does not exist" (url-filename obj))) (setf (url-filename obj) new) (url-recreate-url obj)) url)) (defun erldoc-parse-man-index (url) (let ((table (erldoc-dom-get-element (erldoc-parse-html url) 'table)) (mans)) (erldoc-dom-walk table (lambda (d) (when (eq (car-safe d) 'a) (let ((href (erldoc-dom-get-attribute d 'href))) (when (and href (not (string-match-p "index\\.html\\'" href))) (with-demoted-errors "erldoc-parse-man-index: %S" (push (cons (erldoc-dom-get-text d) (erldoc-fix-path (erldoc-expand-url href url))) mans))))))) (nreverse mans))) (defun erldoc-parse-man (man) (let ((dom (erldoc-parse-html (cdr man))) (table (make-hash-table :test #'equal))) (erldoc-dom-walk (erldoc-dom-get-element-by-id dom "loadscrollpos") (lambda (d) (let ((href (erldoc-dom-get-attribute d 'href))) (when (and href (string-match "#" href)) (puthash (substring href (match-end 0)) (list (concat (car man) ":" (erldoc-strip-string (erldoc-dom-get-text d))) (erldoc-expand-url href (cdr man))) table))))) (let ((span-content (lambda (span) (let ((texts)) (erldoc-dom-walk span (lambda (d) (and (erldoc-dom-text-node-p d) (push (erldoc-strip-string d) texts)))) (and texts (mapconcat 'identity (nreverse texts) " "))))) entries) (erldoc-dom-walk dom (lambda (d) ;; Get the full function signature. (when (and (eq (car-safe d) 'a) (gethash (erldoc-dom-get-attribute d 'name) table)) (let* ((name (erldoc-dom-get-attribute d 'name)) (mfa-url (gethash name table)) (mfa (car mfa-url)) (sig (or (funcall span-content d) (funcall span-content (or (erldoc-dom-get-element d 'span) (cadr (memq d erldoc-dom-walk-siblings)))) (progn (funcall erldoc-no-signature-function "erldoc-parse-man: no sig for %s" mfa) nil)))) (push (append mfa-url (list sig)) entries))) ;; Get data types (when (and (eq (car-safe d) 'a) (string-prefix-p "type-" (or (erldoc-dom-get-attribute d 'name) ""))) (push (list (concat (car man) ":" (funcall span-content d)) (concat (cdr man) "#" (erldoc-dom-get-attribute d 'name)) (funcall span-content erldoc-dom-walk-parent)) entries)))) entries))) (defun erldoc-parse-all (man-index output &optional json) (let* ((output (expand-file-name output)) (table (make-hash-table :size 11503 :test #'equal)) (mans (erldoc-parse-man-index man-index)) (progress 1) (reporter (make-progress-reporter "Parsing Erlang/OTP documentation" progress (length mans))) fails all) (dolist (man mans) (condition-case err (push (erldoc-parse-man man) all) (error (push (error-message-string err) fails))) (accept-process-output nil 0.01) (progress-reporter-update reporter (cl-incf progress))) (when fails (display-warning 'erldoc-parse-all (format "\n\n%s" (mapconcat #'identity fails "\n")) :error)) (progress-reporter-done reporter) (mapc (lambda (x) (puthash (car x) (cdr x) table)) (apply #'nconc (nreverse all))) (with-temp-buffer (if (not json) (pp table (current-buffer)) (let ((json-encoding-pretty-print t)) (insert (json-encode table)))) (unless (file-directory-p (file-name-directory output)) (make-directory (file-name-directory output) t)) (write-region nil nil output nil nil nil 'ask)))) (defun erldoc-otp-release () "Get the otp release version (as string) or nil if not found." (let ((otp (erldoc-dom-get-text (erldoc-dom-get-element (erldoc-parse-html (erldoc-expand-url "index.html" erldoc-man-index)) 'title)))) (and (string-match "[0-9.]+\\'" otp) (match-string 0 otp)))) (defvar erldoc-browse-history nil) (defvar erldoc-lookup-table nil) (defun erldoc-lookup-table () (or erldoc-lookup-table (progn (unless (file-exists-p erldoc-output-file) (let ((of (pcase (erldoc-otp-release) (`nil erldoc-output-file) (ver (concat erldoc-output-file "-" ver))))) (unless (file-exists-p of) (erldoc-parse-all erldoc-man-index of)) (unless (string= erldoc-output-file of) (make-symbolic-link (expand-file-name of) erldoc-output-file)))) (setq erldoc-lookup-table (with-temp-buffer (insert-file-contents erldoc-output-file) (read (current-buffer))))))) (defun erldoc-best-matches (mfa) (pcase mfa ((and `(,m ,f) (let a (erlang-get-function-arity))) (let ((mfa (format "%s:%s/%s" m f a))) (cond ((gethash mfa (erldoc-lookup-table)) (list mfa)) (m (all-completions (concat m ":" f "/") (erldoc-lookup-table))) (t (let* ((mod (erlang-get-module)) (mf1 (and mod (concat mod ":" f "/"))) (mf2 (concat "erlang:" f "/")) (re (concat ":" (regexp-quote f) "/"))) (or (and mf1 (all-completions mf1 (erldoc-lookup-table))) (all-completions mf2 (erldoc-lookup-table)) (cl-loop for k being the hash-keys of (erldoc-lookup-table) when (string-match-p re k) collect k))))))))) ;;;###autoload (defun erldoc-browse (mfa) (interactive (let ((default ;; `erlang-mode-syntax-table' is lazily initialised. (with-syntax-table (or erlang-mode-syntax-table (standard-syntax-table)) (ignore-errors (erldoc-best-matches (or (erlang-get-function-under-point) (save-excursion (goto-char (or (cadr (syntax-ppss)) (point))) (erlang-get-function-under-point)))))))) (list (completing-read (format (if default "Function {%d %s} (default %s): " "Function: ") (length default) (if (= (length default) 1) "guess" "guesses") (car default)) (erldoc-lookup-table) nil t nil 'erldoc-browse-history default)))) (or (stringp mfa) (signal 'wrong-type-argument (list 'string mfa 'mfa))) (browse-url (or (car (gethash mfa (erldoc-lookup-table))) (user-error "No documentation for %s" mfa)))) ;;;###autoload (defun erldoc-apropos (pattern) (interactive "sPattern: ") (with-help-window (help-buffer) (with-current-buffer standard-output (princ (concat "Erldoc apropos pattern: " pattern "\n\n")) (maphash (lambda (k v) (when (string-match-p pattern k) (insert-text-button k :type 'help-url 'help-args (list (car v))) (insert "\n"))) (erldoc-lookup-table))))) (defun erldoc-tokenize-signature (sig) ;; Divide SIG into (MF ARGLIST RETTYPE) (let ((from (if (string-match "\\`.+?(" sig) (1- (match-end 0)) 0)) (to (and (string-match "\\s-*->\\s-*.*?\\'" sig) (match-beginning 0)))) (list (erldoc-strip-string (substring sig 0 from)) (erldoc-strip-string (substring sig from (and to (max from to)))) (and to (erldoc-strip-string (substring sig to)))))) (defun erldoc-format-signature (mod fn) (when (and mod fn (or erldoc-lookup-table (file-exists-p erldoc-output-file))) (let ((re (concat "\\`" mod ":" fn "/\\([0-9]+\\)\\'")) (sigs)) (maphash (lambda (k v) (when (string-match re k) (if (cadr v) (push (cons (string-to-number (match-string 1 k)) (cdr (erldoc-tokenize-signature (cadr v)))) sigs) (funcall erldoc-no-signature-function "erldoc-format-signature: No sig for %s" k)))) (erldoc-lookup-table)) (when sigs ;; Mostly single return type but there are exceptions such as ;; `beam_lib:chunks/2,3'. (let ((single-rettype (cl-reduce (lambda (x1 x2) (and x1 x2 (equal x1 x2) x1)) sigs :key #'cl-caddr)) (sigs (sort sigs #'car-less-than-car))) (if single-rettype (concat mod ":" fn (mapconcat #'cadr sigs " | ") " " single-rettype) (mapconcat (lambda (x) (concat mod ":" fn (nth 1 x) " " (nth 2 x))) sigs "\n"))))))) ;;;###autoload (defun erldoc-eldoc-function () "A function suitable for `eldoc-documentation-function'." (save-excursion (pcase (erlang-get-function-under-point) (`(,_ nil) ) (`(nil ,fn) (erldoc-format-signature "erlang" fn)) (`(,mod ,fn) (erldoc-format-signature mod fn))))) (defun erldoc-parse-eeps-index () (let* ((url "http://www.erlang.org/eeps/") (table (catch 'return (erldoc-dom-walk (erldoc-parse-html url) (lambda (d) (and (eq (car-safe d) 'table) (equal (erldoc-dom-get-attribute d 'summary) "Numerical Index of EEPs") (throw 'return d)))))) (fix-title (lambda (title) (replace-regexp-in-string "`` *" "" (replace-regexp-in-string " *``, *" " by " title)))) (result)) (erldoc-dom-walk table (lambda (d) (when (eq (car-safe d) 'a) (push (cons (funcall fix-title (erldoc-dom-get-attribute d 'title)) (erldoc-expand-url (erldoc-dom-get-attribute d 'href) url)) result)))) (nreverse result))) (defvar erldoc-user-guides nil) (defvar erldoc-missing-user-guides '("compiler" "kernel" "os_mon" "parsetools") "List of standard Erlang applications with no user guides.") ;; Search in `code:lib_dir/0' using find LIB_DIR -type f -name ;; '*_app.html'. (defvar erldoc-app-manuals '("crypto" "diameter" "erl_docgen" "kernel" "observer" "os_mon" "runtime_tools" "sasl" "snmp" "ssl" "test_server" ("ssh" . "SSH") ("stdlib" . "STDLIB")) "List of applications that come with a manual.") (defun erldoc-user-guide-chapters (user-guide) (pcase-let ((`(,name . ,url) user-guide)) (unless (member name erldoc-missing-user-guides) (let ((chaps (erldoc-dom-get-elements-by-id (erldoc-dom-get-element-by-id (erldoc-parse-html url) "leftnav") "no"))) (or chaps (warn "erldoc-user-guide-chapters no chapters found for `%s'" (cdr user-guide))) (mapcar (lambda (li) (cons (concat name "#" (erldoc-dom-get-attribute li 'title)) (erldoc-expand-url (erldoc-dom-get-attribute (erldoc-dom-get-element li 'a) 'href) url))) chaps))))) (defun erldoc-user-guides-1 () (let ((url (erldoc-expand-url "applications.html" erldoc-man-index)) app-guides app-mans) (erldoc-dom-walk (erldoc-parse-html url) (lambda (d) (when (and (eq (car-safe d) 'a) (not (string-match-p "\\`[0-9.]+\\'" (erldoc-dom-get-text d)))) (with-demoted-errors "erldoc-user-guides-1: %S" (let ((name (erldoc-strip-string (erldoc-dom-get-text d))) (index-page (erldoc-fix-path (erldoc-expand-url (erldoc-dom-get-attribute d 'href) url)))) (push (cons name (if (member name erldoc-missing-user-guides) index-page (erldoc-expand-url "users_guide.html" index-page))) app-guides) ;; Collect application manuals. (pcase (assoc name (mapcar (lambda (x) (if (consp x) x (cons x x))) erldoc-app-manuals)) (`(,_ . ,manual) (push (cons name (erldoc-expand-url (format "%s_app.html" manual) index-page)) app-mans)))))))) (list (nreverse app-guides) (nreverse app-mans)))) (defun erldoc-user-guides () (or erldoc-user-guides (let ((file (concat erldoc-output-file "-topics"))) (unless (file-exists-p file) (unless (file-directory-p (file-name-directory file)) (make-directory (file-name-directory file) t)) (with-temp-buffer (pcase-let ((`(,guides ,mans) (erldoc-user-guides-1))) (pp (append (cl-mapcan #'erldoc-user-guide-chapters (append (mapcar (lambda (dir) (cons dir (erldoc-expand-url (concat dir "/users_guide.html") erldoc-man-index))) '("design_principles" "efficiency_guide" "embedded" "getting_started" "installation_guide" "oam" "programming_examples" "reference_manual" "system_architecture_intro" "system_principles" "tutorial")) guides)) (mapcar (lambda (man) (pcase-let ((`(,name . ,url) man)) (cons (concat name " (App)") url))) mans) (erldoc-parse-eeps-index)) (current-buffer))) (write-region nil nil file nil nil nil 'ask))) (setq erldoc-user-guides (with-temp-buffer (insert-file-contents file) (read (current-buffer))))))) ;;;###autoload (defun erldoc-browse-topic (topic) (interactive (list (completing-read "User guide: " (erldoc-user-guides) nil t))) (browse-url (cdr (assoc topic (erldoc-user-guides))))) (provide 'erldoc) ;; Local variables: ;; coding: utf-8 ;; indent-tabs-mode: nil ;; End: ;;; erldoc.el ends here