2298 lines
97 KiB

;;; org-glossary.el --- Defined terms and abbreviations in Org -*- lexical-binding: t; -*-
;; Copyright (C) 2022 TEC
;; Author: TEC <>
;; Maintainer: TEC <>
;; Created: June 05, 2022
;; Modified: June 05, 2022
;; Version: 0.0.1
;; Keywords: abbrev docs tools
;; Homepage:
;; Package-Requires: ((emacs "28.1") (org "9.6"))
;; This file is not part of GNU Emacs.
;;; Commentary:
;; Defined terms and abbreviations in Org
;;; Plan:
;; TODO jump to usages
;; TODO support term-links with multiple targets
;; TODO M-x org-glossary-list-defined-terms
;; something vertico + maginalia style
;; TODO M-x org-glossary-find-expanded-terms
;; this would be primaraly useful for acronyms.
;; REVIEW maybe support generating the glossary/acronym etc.
;; in the file, like org-toc.?
;; This is complicated by the way we treat * Glossary sections etc.
;; TODO support references inside glossary definitions, being careful
;; to avoid circular references.
;; (long term)
;; TODO abstract the short<->long/expansion part of this package into
;; its own thing
;; TODO support looking for usages of a term in other files, maybe?
;; TODO support plural forms in substitution display.
;;; Code:
(require 'org)
(require 'org-element)
(require 'cl-lib)
(require 'subr-x)
(require 'ox))
(defgroup org-glossary nil
"Defined terms and abbreviations in Org."
:group 'org
:prefix "org-glossary-")
(defvar org-glossary--heading-names) ; For the byte-compiler.
(defvar org-glossary-mode) ; For the byte-compiler.
(defcustom org-glossary-headings
'(("Glossary" . glossary)
("Acronyms" . acronym)
("Index" . index)
("Text Substitutions" . substitution))
"An alist of special heading names and their correspanding type.
During export, all matching headings will be removed in their
entirity (including subtrees).
If setting this outside of the customisation interface, be sure
to update `org-glossary--heading-names' appropriately."
:type '(alist :key-type (string :tag "Heading title")
:value-type (symbol :tag "Entry type"))
:set (lambda (symbol value)
(setq org-glossary--heading-names
(mapcar #'car value))
(set-default-toplevel-value symbol value)))
(defvar org-glossary--heading-names
(mapcar #'car org-glossary-headings)
"The heading names which correspand to a glossary type.")
(defcustom org-glossary-toplevel-only t
"Whether all glossary definition sections must be toplevel.
If nil, they will be recognised anywhere in the document."
:type 'boolean)
(defcustom org-glossary-automatic t
"Pick up on terms in plain text."
:type 'boolean)
(defcustom org-glossary-plural-function #'org-glossary-english-plural
"A function which generates the plural form of a word."
:type 'function)
(defcustom org-glossary-canonicalise-aliases nil
"Whether aliases should be canonicalised."
:type 'boolean)
(defcustom org-glossary-group-ui t
"Group term definitions by type.
In practice, if using Emacs 28, this allows you to turn off
grouping, and add the target type to the annotation instead."
:type 'boolean)
(defcustom org-glossary-global-terms nil
"A list of globally availible term sources.
Each term should either be a string interpreted as an #+include
keyword's value, or a plist of the form emitted by
:type '(repeat (choice string plist)))
(defcustom org-glossary-collection-root nil
"A base path prefixed to any per-document glossary sources.
If this is set to a directory, ensure that you include the trailing slash."
:type '(choice (const nil) (string :tag "Path")))
(defvar-local org-glossary--extra-term-sources nil
"A list of locations outside the current document that should be sourced from.
This is constructed using `org-glossary--get-extra-term-sources'.
See its docstring for more information.")
(defcustom org-glossary-default-print-parameters
'(:type (glossary acronym index)
:level 0
:consume nil
:all nil
:only-contents nil)
"The default print parameters.
These can be set by #+print_glossary in babel :key value style."
:type 'plist)
(defcustom org-glossary-print-letter-minimums '(12 . 3)
"The minimum number of terms with distinct and the same letter to print letters.
More specifically, a cons cell containing:
- The minimum number of distinct first letters among the used terms
- The minimum maximum number of terms with the same letter
before the :letter-heading templates of
`org-glossary-export-specs' should be applied.
For instance, with the default value of \\=(12 . 3) the terms
must start with at least 12 different letters, and there must be
at least three terms that start with the same letter."
:type '(cons integer integer))
(defcustom org-glossary-export-specs
'((t (t :use "%t"
:first-use "%u"
:definition "%t"
:backref "%r"
:backref-seperator ", "
:heading ""
:category-heading "* %c\n"
:letter-heading "*%L*\n"
:definition-structure-preamble ""
:definition-structure "*%d*\\emsp{}%v\\ensp{}%b\n"
:alias-value "See [[gls:0:%k]].")
(glossary :heading "* Glossary")
(acronym :heading "* Acronyms"
:first-use "%v (%u)")
(index :heading "* Index"
:definition-structure "%d\\ensp{}%b\n")
(substitution :heading ""
:use "%v"
:definition-structure ""
:category-heading ""
:letter-heading ""))
(latex (t :use "\\protect\\hyperlink{gls-%K}{\\label{gls-%K-use-%r}%t}"
:definition "\\hypertarget{gls-%K}{%t}"
:backref "\\pageref{gls-%K-use-%r}"))
(html (t :use "<a class=\"org-gls\" href=\"#gls.%K\" id=\"glsr.%K.%r\">%t</a>"
:definition "<span class=\"org-glsdef\" id=\"gls.%K\">%t</span>"
:backref "<a class=\"org-glsdef\" href=\"#glsr.%K.%r\">%r</a>"))
(ascii (t :definition-structure "*%d* %v [%n uses]\n")
(index :definition-structure "%d [%n uses]\n"))
(org (t :use "<<gr;%K;%r>>[[g;%K][%t]]"
:backref "[[gr;%K;%r][%r]]"
:definition-structure "- <<g;%K>>%t :: %v\\ensp{}%b")
(index :definition-structure "- <<g;%K>>%t\\ensp{}%b")))
"Alist of export backends and template set alists.
Each template set alist has the term type (e.g. acronym) as the
car, and the templates set as the cdr.
The backend set associated with t is used as the default backend,
and likewise the template set associated with t used as the the
default template set.
Each template set is a plist with term forms as the keys and
the templates for the forms as the the values.
The following term forms as recognised for all template specs:
There are also four special forms for the default template spec:
Within each template, the following format specs are applied:
%t the term
%v the term value
%k the term key
%K the term key nonce
%r the term reference index (applicable to :use, :first-use,
:backref, and :alias-value)
%n the number of term references (i.e. max %r)
%c the category of the term
In :use and :first-use, %t/%v are pluralised and capitalised as
appropriate. The :first-use template can also use %u to refer to
the value of :use.
The default backend defines four special forms, expanded at the
start of the export process:
- The :definition-structure form is used as the template for the
whole definition entry, and uses the format specs %d, %v, %b
for the definition term, value, and backreferences respectively.
- The :letter-heading form is inserted before a block of terms
starting with the letter, given by the format spec %l and %L in
lower and upper case respectively.
- The :category-heading form is inserted before a block of terms
all assigned a particular category, given by the format spec %c.
- The :alias-value form is used as the value (%v) when expanding an alias
definition. Most other values are inherited from the canonical form.
Instead of a format string, one can also provide a function as a
template spec so long as it matches the function signature of
The literal content of :definition-structure-preamble is inserted
before the first :definition-structure in each block of
If using cleverref with LaTeX, making use of the \\labelcpageref
command like so is recommended:
(org-glossary-set-export-spec 'latex t
:backref \"gls-%k-use-%r\"
:backref-seperator \",\"
\"*%d*\\emsp{}%v\\ensp{}@@latex:\\ifnum%n>0 \\labelcpageref{@@%b@@latex:}\\fi@@\n\")
TODO rewrite for clarity."
:type '(alist :key-type (symbol :tag "Backend")
(alist :key-type (symbol :tag "Type")
(plist :value-type
(string :tag "Template")))))
(defcustom org-glossary-fontify-types-differently t
"Whether to use the org-glossary-TYPE-term faces.
Or just use the org-glossary-term face for everything."
:type 'boolean)
(defcustom org-glossary-fontify-type-faces
'((glossary . org-glossary-glossary-term)
(acronym . org-glossary-acronym-term)
(index . org-glossary-index-term)
(substitute . org-glossary-substitution-term))
"An alist of types and the faces that should be used.
This only applies when `org-glossary-fontify-types-differently'
is non-nil."
:type '(alist :key-type (symbol :tag "Type")
:value-type face))
(defcustom org-glossary-snippet-fontication-hooks
;; Known desirable hooks, if they exist.
(lambda (h)
(memq h '(org-toggle-pretty-entities +org-pretty-mode
org-modern-mode org-glossary-mode)))
"A stripped down version of `org-mode-hook' for fontification.
This is bound as `org-mode-hook' during snippet/substitution fontification.
It allows for expensive but unneeded hooks to be skipped.
See also `org-glossary-fontify-displayed-substitute'."
:type '(repeat function))
(defcustom org-glossary-display-substitute-value t
"Whether to display substitutions as their value.
Requires `org-glossary-fontify-types-differently' to be non-nil.
See also `org-glossary-fontify-displayed-substitute'."
:type 'boolean)
(defcustom org-glossary-fontify-displayed-substitute t
"Whether to fontify displayed substitutions values.
Requires `org-glossary-display-substitute-value' to be non-nil.
See also `org-glossary-snippet-fontication-hooks'."
:type 'boolean)
(defface org-glossary-term
'((t :inherit (org-agenda-date-today org-link) :weight normal))
"Base face used for term references.")
(defface org-glossary-glossary-term
'((t :inherit org-glossary-term))
"Face used for term references.")
(defface org-glossary-acronym-term
'((t :inherit org-glossary-term))
"Face used for term references.")
(defface org-glossary-index-term
'((t :inherit (org-scheduled-previously org-glossary-term)))
"Face used for term references.")
(defface org-glossary-substitution-term
'((t :inherit (org-archived org-glossary-term)))
"Face used for term references.")
(defface org-glossary-substituted-value
'((t :inherit default))
"Face used for substitution values.")
(defvar-local org-glossary--terms nil
"The currently known terms.")
(defvar org-glossary--paths-to-update nil
"List of paths whos terms should be updated.")
(defvar org-glossary--path-update-timer nil
"The timer which will update paths, should it exist.")
(defvar org-glossary--path-dependencies nil
"Alist of definition source paths and their dependent paths.")
(defcustom org-glossary-idle-update-period 0.5
"Idle time in seconds used when updating term definitions.
Set to nil to disable automatic update propagation."
:type '(choice number (const nil :tag "Do not update")))
;;; Obtaining term definitions
(defun org-glossary--get-terms (&optional path-spec no-extra-sources already-included)
"Obtain all known terms in the current buffer.
Terms from `org-glossary--extra-term-sources' will be added
unless PATH-SPEC is non-nil and NO-EXTRA-SOURCES nil."
(let* ((path-spec (org-glossary--complete-path-spec path-spec))
(and (not (member path-spec already-included))
(org-glossary--get-terms-oneshot path-spec))))
(setq already-included (nconc already-included (list path-spec)))
(apply #'append
(plist-get term-source :terms)
(mapcar (lambda (p) (org-glossary--get-terms p t already-included))
(cl-set-difference (plist-get term-source :included)
(not no-extra-sources)
(defun org-glossary--maybe-add-extra-terms (term-getter term-set do-it-p &optional already-included)
"Apply TERM-GETTER to extra term sources add them to TERM-SET.
The extra terms sources are the elements of `org-glossary--extra-term-sources'.
TERM-GETTER will be called with three arguments:
- the term source
- t
If DO-IT-P is nil, then nothing will be done and TERM-SET will be returned."
(if do-it-p
(let ((accumulation term-set))
(dolist (term-source (cl-set-difference
(mapcar #'org-glossary--complete-path-spec
(if (or (bufferp term-source)
(file-exists-p (plist-get term-source :file)))
(setq accumulation
(append accumulation
(funcall term-getter term-source t already-included)))
'(org-glossary missing-source)
(format "Glossary source `%s' does not exist! Skipping..."
(plist-get term-source :file)))))
(defun org-glossary--get-terms-oneshot (&optional path-spec)
"Optain all terms defined in PATH-SPEC."
(let* ((path-spec (org-glossary--complete-path-spec path-spec))
((bufferp path-spec) path-spec)
((equal (plist-get path-spec :file)
(if path-buffer
(with-current-buffer path-buffer
(setq buffer-file-name (plist-get path-spec :file))
(org-glossary--include-once path-spec)
(set-buffer-modified-p nil)
(terms (org-glossary--extract-terms parse-tree)))
(list :path path-spec
:scan-time (current-time)
:terms terms
:terms-hash (sxhash terms)
(lambda (location)
location (and (plist-get path-spec :file)
(file-name-directory (plist-get path-spec :file)))))
(org-element-map parse-tree 'keyword
(lambda (kwd)
(when (string= "INCLUDE" (org-element-property :key kwd))
(org-element-property :value kwd)))))
(org-glossary--get-extra-term-sources parse-tree))))
(defun org-glossary--complete-path-spec (&optional path-spec)
"Given a tentative PATH-SPEC, try to get a proper one.
The PATH-SPEC is formed with respect to the current buffer."
(or (and (stringp path-spec)
(org-glossary--parse-include-value path-spec))
(and (buffer-file-name)
(format "%S" (buffer-file-name))))
(defun org-glossary--include-once (parameters)
"Include content based on PARAMETERS."
(unless (eq (plist-get parameters :env) 'literal)
(require 'ox)
(let ((lines (plist-get parameters :lines))
(file (plist-get parameters :file))
(location (plist-get parameters :location))
(org-inhibit-startup t))
(if location
file location
(plist-get parameters :only-contents)
(plist-get parameters :minlevel)
nil nil
(defun org-glossary--parse-include-value (value &optional dir)
"Extract the useful parameters from #+include: VALUE.
The file name is resolved against DIR."
(when value
(let* (location
(and (string-match "^\\(\".+?\"\\|\\S-+\\)\\(?:\\s-+\\|$\\)" value)
(let ((matched (match-string 1 value))
(when (string-match "\\(::\\(.*?\\)\\)\"?\\'"
(setq location (match-string 2 matched))
(setq matched
(replace-match "" nil nil matched 1)))
(setq stripped (org-strip-quotes matched))
(if (org-url-p stripped)
(expand-file-name stripped dir))))
(setq value (replace-match "" nil nil value)))))
(and (string-match ":only-contents *\\([^: \r\t\n]\\S-*\\)?"
(prog1 (org-not-nil (match-string 1 value))
(setq value (replace-match "" nil nil value)))))
(and (string-match
":lines +\"\\([0-9]*-[0-9]*\\)\""
(prog1 (match-string 1 value)
(setq value (replace-match "" nil nil value)))))
(env (cond
((string-match "\\<example\\>" value) 'literal)
((string-match "\\<export\\(?: +\\(.*\\)\\)?" value)
((string-match "\\<src\\(?: +\\(.*\\)\\)?" value)
;; Minimal level of included file defaults to the
;; child level of the current headline, if any, or
;; one. It only applies is the file is meant to be
;; included as an Org one.
(and (not env)
(if (string-match ":minlevel +\\([0-9]+\\)" value)
(prog1 (string-to-number (match-string 1 value))
(setq value (replace-match "" nil nil value)))
(get-text-property (point)
(list :file (if (org-url-p file) file
(expand-file-name file dir))
:location location
:only-contents only-contents
:line lines
:env env
:minlevel minlevel))))
;;; Term cache
(defvar org-glossary--terms-cache nil
"Cached definition sources.
An alist with entries of the form:
where PATH-SPEC is an absolute #+include path, and TERM-CACHE-PLIST
a plist of the form:
:scan-time TIME-LIST
:terms TERM-LIST
:terms-hash (sxhash TERM-LIST)
:extra-term-sources LIST-OF-PATH-STRINGS)")
(defvar org-glossary--quicklookup-cache) ; For the byte-compiler.
(defvar org-glossary--help-echo-cache) ; For the byte-compiler.
(defun org-glossary--get-terms-cached (&optional path-spec no-extra-sources already-included)
"Obtain all known terms in the current buffer, using the cache.
`org-glossary--extra-term-sources' will be used unless PATH-SPEC
is non-nil and NO-EXTRA-SOURCES nil.
If a source that could have contributed to the quicklookup cache is updated,
then the quicklookup cache (`org-glossary--quicklookup-cache') will be cleared."
(let* ((path-spec (org-glossary--complete-path-spec path-spec))
(term-source-cached (assoc path-spec org-glossary--terms-cache))
(cached-path (plist-get (cdr term-source-cached) :path))
(cached-file (plist-get cached-path :file))
(and term-source-cached
(not (bufferp cached-path))
(or (org-url-p cached-file)
(and (file-exists-p cached-file)
(if (equal cached-file (buffer-file-name))
(not (buffer-modified-p)) t)
(not ; scan time >= mtime (scan time !< mtime)
(time-less-p (plist-get (cdr term-source-cached) :scan-time)
(file-attributes cached-file))))))))
(or (and (member path-spec already-included)
'(:terms nil))
(and term-source-cached
(if cache-valid t
(delq term-source-cached org-glossary--terms-cache)
(cdr term-source-cached))
(cdar (push (cons path-spec
(org-glossary--get-terms-oneshot path-spec))
;; If updating a source that could already be part of the quicklookup cache,
;; then clear the quicklookup cache to prevent outdated entries from persisting.
;; We should also clear the help-echo cache while we're at it.
(when (and (not (hash-table-empty-p org-glossary--quicklookup-cache))
term-source-cached (not cache-valid))
(setq org-glossary--quicklookup-cache (make-hash-table :test #'equal)
org-glossary--help-echo-cache (make-hash-table :test #'equal)))
(setq already-included (nconc already-included (list path-spec)))
(apply #'append
(plist-get term-source :terms)
(mapcar (lambda (p) (org-glossary--get-terms-cached p t already-included))
(cl-set-difference (plist-get term-source :included)
(not no-extra-sources)
(defun org-glossary-clear-cache ()
"Clear the global term cache."
(setq org-glossary--terms-cache nil))
;;; Term identification
(defun org-glossary--extract-terms (&optional parse-tree)
"Find all terms defined in the current buffer.
Note that this removes definition values from PARSE-TREE by
side-effect when it is provided."
(let* ((parse-tree (or parse-tree
(buffer-file-name (org-element-property :path parse-tree)))
(apply #'nconc
(lambda (heading)
(and (member (org-element-property :raw-value heading)
(apply #'nconc
(org-element-contents heading)
(lambda (lst)
(org-element-contents lst)
nil nil 'item))))))
nil nil
(and org-glossary-toplevel-only 'headline)))))
(defun org-glossary--entry-from-item (item)
"Destructively build a glossary entry from a ITEM."
(let* ((term-str (string-trim
(or (org-element-property :tag item)
(org-element-contents item))))))
(case-fold-search nil)
(lambda (word)
(if (or (string-match-p "^[[:upper:]][^[:space:]][^[:upper:]]+$" word)
;; NOTE This following rule makes sense, but unfortunately is
;; english-only. It would be good to support a similar case in
;; more languages at some point.
(string-match-p "^\\(?:A\\|An\\)[[:space:]][^[:upper:]]+$" word))
(concat (string (downcase (aref word 0))) (substring word 1))
(keys-terms (split-string term-str "[ \t]*=[ \t]*"))
(term-and-plural (split-string (car (last keys-terms)) "[ \t]*,[ \t]*"))
(term (funcall sentancecase-to-lowercase (car term-and-plural)))
(plural (funcall sentancecase-to-lowercase
(or (cadr term-and-plural)
(funcall org-glossary-plural-function term))))
(key-and-plural (split-string (car keys-terms) "[ \t]*,[ \t]*"))
(key (funcall sentancecase-to-lowercase (car key-and-plural)))
(key-plural (funcall sentancecase-to-lowercase
(or (cadr key-and-plural)
(funcall org-glossary-plural-function key))))
(type-category (org-glossary--entry-type-category
(org-element-lineage item '(headline))))
(item-contents (and (org-element-property :tag item)
(org-element-contents item)))
(value (mapcar #'org-element-extract-element item-contents)))
(list :key key
:key-plural (and (not (string-empty-p key-plural))
(not (string= key key-plural))
:key-nonce (org-glossary--key-nonce key)
:term term
:term-plural plural
:alias-for nil
:type (car type-category)
:category (cdr type-category)
:value value
:definition-file (or (buffer-file-name) (current-buffer))
:definition-pos (+ (org-element-property :begin item) 2)
:extracted nil
:uses nil)))
(defvar org-glossary--category-heading-tag) ; For the byte-compiler.
(defun org-glossary--entry-type-category (datum)
"Determine whether DATUM is a glossary or acronym entry."
((null datum) nil)
((eq (org-element-type datum) 'org-data) nil)
((eq (org-element-type datum) 'headline)
(let* ((title (org-element-property :raw-value datum))
(type (alist-get title org-glossary-headings
nil nil #'string=))
(recurse (unless type (org-glossary--entry-type-category
(org-element-lineage datum '(headline)))))
(type (cons type nil))
((member org-glossary--category-heading-tag
(org-element-property :tags datum))
(cons (car recurse) title))
(t recurse))))
(t (org-glossary--entry-type-category (org-element-lineage datum '(headline))))))
(defvar org-glossary--category-heading-tag "category"
"The tag signifying that the heading correspands to a category of terms.")
(defun org-glossary--identify-alias-terms (terms)
"Search for aliases in TERMS, and update term entries accordingly."
(let ((key-term-map (make-hash-table :test #'equal :size (length terms))))
(dolist (term-entry terms)
(puthash (plist-get term-entry :key) term-entry key-term-map))
(dolist (term-entry terms)
(when-let ((value (plist-get term-entry :value))
(value-str (string-trim (org-element-interpret-data value)))
(associated-term (gethash value-str key-term-map)))
(plist-put term-entry :alias-for associated-term)
(plist-put term-entry :value (plist-get associated-term :value)))))
(defvar org-glossary--key-nonces (make-hash-table :test #'equal)
"The currently set key nonces.")
(defun org-glossary--key-nonce (key)
"Return the nonce for KEY, assigning one if not already set."
(or (gethash key org-glossary--key-nonces)
(puthash key (1+ (hash-table-count org-glossary--key-nonces))
;;; Term usage
(defun org-glossary-apply-terms (terms &optional no-modify no-number keep-unused)
"Replace occurances of the TERMS with links.
This returns a copy of TERMS with references recorded in :uses.
When NO-MODIFY is non-nil, neither buffer content nor TERMS will be modified.
When NO-NUMBER is non-nil, all links created or modified shall not include
a reference number.
When KEEP-UNUSED is non-nil, unused terms will be included in the result."
(interactive (list org-glossary--terms nil t))
(let ((terms-mrx (org-glossary--mrx-construct-from-terms terms))
(search-spaces-regexp "[ \t\n][ \t]*")
(start-time (float-time))
(last-redisplay (float-time))
terms-used element-context element-at-point)
(let ((register-term
(lambda (term-entry)
(push (plist-get term-entry :key) terms-used)
(when (plist-get term-entry :alias-for)
(push (plist-get (plist-get term-entry :alias-for) :key)
(setq terms (org-glossary--strip-uses terms))
(org-glossary--identify-alias-terms terms)
(goto-char (point-min))
(while (org-glossary--mrx-search-forward terms-mrx)
(when (> (- (float-time) last-redisplay) 0.4)
(let (message-log-max)
(message "Scanning for term usage: (%s%%)"
(/ (* 100 (point)) (point-max))))
(sit-for 0.01)
(setq last-redisplay (float-time)))
(setq element-at-point (org-element-at-point)
element-context (org-element-context element-at-point)))
((or (org-glossary--within-definition-p element-context)
(eq 'headline (org-element-type element-at-point))
(and (eq 'keyword (org-element-type element-at-point))
(not (member (org-element-property :key element-at-point)
nil) ; Skip, not a valid reference.
((eq 'link (org-element-type element-context))
(funcall register-term
terms element-context no-modify no-number)))
((and org-glossary-automatic
(memq 'link (org-element-restriction element-context)))
(funcall register-term
terms no-modify no-number))))))
(when (> (- (float-time) start-time) 0.1)
(message "Scanned for term usage in buffer (took %.2f seconds)."
(- (float-time) start-time)))
(if keep-unused
(setq terms-used (cl-delete-duplicates (delq nil terms-used) :test #'string=))
(delq nil
(lambda (trm)
(when (member (plist-get trm :key) terms-used)
(defun org-glossary--strip-uses (terms &optional no-modify)
"Either modify or return a copy of TERMS with all :uses set to nil.
Behaviour is set according to NO-MODIFY."
(if no-modify
(lambda (trm)
(if (plist-get trm :uses)
(org-combine-plists trm '(:uses nil))
(dolist (term-entry terms)
(plist-put term-entry :uses nil))
(defvar org-glossary--mrx-last-tag nil
"The tag of the last multi-rx matched by `org-glossary--multi-rx'.")
(defun org-glossary--mrx-search-forward (tagged-patterns &optional limit case-insensitive)
"Find the closest matching pattern in TAGGED-PATTERNS before LIMIT.
Each entry in the list TAGGED-PATTERNS should be of the form:
In the case of two equally close matches from TAGGED-PATTERNS,
the longest match will be used.
`case-fold-search' is locally bound to CASE-INSENSITIVE.
This is necessitated by problems when trying to apply
`regexp-opt' to many items, which can trigger:
Lisp error: (invalid-regexp \"Regular expression too big\")"
(let ((match-start most-positive-fixnum)
(match-stop -1)
(case-fold-search case-insensitive)
the-match tag)
(dolist (t-pat tagged-patterns)
(when (and (re-search-forward (cdr t-pat) limit t)
(or (< (match-beginning 0) match-start)
(and (= (match-beginning 0) match-start)
(> (match-end 0) match-stop))))
(setq the-match (match-data)
match-start (match-beginning 0)
match-stop (match-end 0)
tag (car t-pat)))))
(set-match-data the-match)
(and the-match
(setq org-glossary--mrx-last-tag tag)
(goto-char match-stop))))
(defvar org-glossary--mrx-max-bin-size 800
"The maximum number of strings that should be combined into a single regexp.
Larger is better, however typically 'invalid-regexp \"Regular
expression too big\"' is seen with around 1000+ terms.")
(defun org-glossary--mrx-construct (&rest tagged-strings)
(let (bins accumulated (n 0))
(dolist (tag-strs tagged-strings)
(dolist (str (delete-dups (sort (copy-sequence (cdr tag-strs)) #'string<)))
(if (< n org-glossary--mrx-max-bin-size)
(progn (push str accumulated)
(setq n (1+ n)))
(push (cons (car tag-strs) (regexp-opt accumulated 'words)) bins)
(setq accumulated nil
n 0)))
(when accumulated
(push (cons (car tag-strs) (regexp-opt accumulated 'words)) bins)
(setq accumulated nil
n 0)))
(defun org-glossary--mrx-construct-from-terms (terms)
"Construct a multi-rx from TERMS."
(let ((term-collect
(lambda (terms key)
(apply #'nconc
(lambda (trm)
(when-let ((term-str (plist-get trm key))
(term-letter1 (aref term-str 0)))
(if (eq term-letter1 (upcase term-letter1))
(list term-str)
(list term-str (concat (string (upcase term-letter1))
(substring term-str 1))))))
(cons 'singular (funcall term-collect terms :key))
(cons 'plural (funcall term-collect terms :key-plural)))))
(defun org-glossary--within-definition-p (datum)
"Whether DATUM exists within a term definition subtree."
(when datum
(if (and (eq 'headline (org-element-type datum))
(org-glossary--definition-heading-p datum))
(save-match-data (org-element-lineage datum '(headline)))))))
(defun org-glossary--definition-heading-p (heading)
"Whether HEADING is recognised as a definition heading."
(and (member (org-element-property :raw-value heading)
(or (= 1 (org-element-property :level heading))
(not org-glossary-toplevel-only))))
(defun org-glossary--update-link (terms link &optional no-modify no-number)
"Register LINK's reference to a term in TERMS, and update numbering.
When NO-MODIFY is non-nil, the reference will be lodged in
TERMS but the buffer content left unmodified.
When NO-NUMBER is non-nil, no reference number shall be inserted."
(when (member (org-element-property :type link)
'("gls" "glspl" "Gls" "Glspl"))
(let* ((trm (replace-regexp-in-string
"^.+?:" ""
(org-element-property :path link)))
(term-entry (org-glossary--find-term-entry terms trm :key))
(index (org-glossary--record-term-usage term-entry link))
(contents-begin (org-element-property :contents-begin link))
(contents-end (org-element-property :contents-end link)))
link :path (if no-number trm
(concat (number-to-string index) ":" trm)))
(unless no-modify
(org-element-property :begin link)
(- (org-element-property :end link)
(org-element-property :post-blank link))
(lambda ()
(and contents-begin contents-end
(buffer-substring contents-begin contents-end))))))
(defun org-glossary--update-plain (terms &optional no-modify no-number)
"Register a reference to a term in TERMS, and convert to a link.
It is assumed that the term reference has just been matched with
a regexp of the form given by `org-glossary--construct-regexp'
and the match data is intact.
When NO-MODIFY is non-nil, the reference will be lodged in
TERMS but the buffer content left unmodified.
When NO-NUMBER is non-nil, no reference number shall be inserted."
(let ((term-str
"[ \n\t]+" " "
(substring-no-properties (match-string 0))))
(plural-p (eq org-glossary--mrx-last-tag 'plural))
(case-fold-search nil)
capitalized-p term-entry)
(setq term-entry
terms term-str (if plural-p :key-plural :key)))
(unless term-entry
(setq term-entry
(concat (string (downcase (aref term-str 0)))
(substring term-str 1))
(if plural-p :key-plural :key)))
(when term-entry
(setq capitalized-p t)))
(when term-entry
(unless no-modify
(unless no-number
(1+ (length (plist-get
(or (plist-get term-entry :alias-for) term-entry)
plural-p capitalized-p)
t t))
(org-glossary--record-term-usage term-entry (org-element-context))
(defun org-glossary--find-term-entry (terms term-key key)
"Find any term in TERMS where KEY is TERM-KEY."
(cl-some (lambda (trm)
(when (equal term-key (plist-get trm key))
(defun org-glossary--record-term-usage (term-entry record)
"Record TERM-ENTRY's usage with RECORD, and give the use index."
(let* ((canonical-term (or (plist-get term-entry :alias-for) term-entry))
(uses (plist-get canonical-term :uses))
(index (1+ (or (caar uses) 0))))
;; (plist-put canonical-term :uses (nconc (list (cons index record)) uses))
(push (cons index record) (plist-get canonical-term :uses))
(defun org-glossary--clear-term-usage (term-entry)
"Clear the :uses slot of TERM-ENTRY."
(plist-put term-entry :uses nil))
(defun org-glossary--term-replacement (term-entry &optional index plural-p capitalized-p)
"Construct a string refering to the TERM-ENTRY"
(:type ,(cond
((and plural-p capitalized-p) "Glspl")
(capitalized-p "Gls")
(plural-p "glspl")
(t "gls"))
:path ,(if index
(concat (number-to-string index)
":" (plist-get term-entry :key))
(plist-get term-entry :key))
:format bracket))))
;;; Automatic term updating
(defun org-glossary--register-buffer-dependencies (&optional path-spec)
"Watch all definition dependencies of PATH-SPEC for updates."
(let ((path-spec (or path-spec (org-glossary--complete-path-spec))))
(let* ((term-cache (cdr (assoc path-spec org-glossary--terms-cache)))
(included (plist-get term-cache :included))
(extras (mapcar #'org-glossary--parse-include-value
(plist-get term-cache :extra-term-sources))))
(org-glossary--deregister-buffer-dependencies path-spec)
(dolist (dep-pspec (delete path-spec (nconc extras included)))
(if-let ((dep-files (assoc dep-pspec org-glossary--path-dependencies)))
(unless (member path-spec dep-files)
(push path-spec (cdr dep-files)))
(push (list dep-pspec path-spec) org-glossary--path-dependencies))
(org-glossary--register-buffer-dependencies dep-pspec)))))
(defun org-glossary--deregister-buffer-dependencies (&optional path-spec)
"Stop watching definition dependencies for PATH-SPEC."
(let ((path-spec (or path-spec (org-glossary--complete-path-spec))))
(dolist (buf-dep org-glossary--path-dependencies)
(setcdr buf-dep (delete path-spec (cdr buf-dep)))
(unless (cdr buf-dep)
(setq org-glossary--path-dependencies
(delete buf-dep org-glossary--path-dependencies))
(org-glossary--deregister-buffer-dependencies (car buf-dep))))))
(defun org-glossary--propagate-buffer-updates (path-spec)
"Update all buffers whos definitions depend on PATH-SPEC.
The actual update is performed by `org-glossary--update-buffers'."
(when-let ((dependants (cdr (assoc path-spec org-glossary--path-dependencies))))
(unless org-glossary--path-update-timer
(setq org-glossary--path-update-timer
(run-with-idle-timer org-glossary-idle-update-period nil
(dolist (dep dependants)
(add-to-list 'org-glossary--paths-to-update dep)
(org-glossary--propagate-buffer-updates dep))))
(defun org-glossary--detect-updates-and-propagate (&optional path-spec)
"Propagate any term definition updates originating from PATH-SPEC."
(when org-glossary-idle-update-period
(let* ((path-spec (or path-spec (org-glossary--complete-path-spec)))
(cache-entry (cdr (assoc path-spec org-glossary--terms-cache)))
(old-term-hash (plist-get cache-entry :terms-hash))
(sort (plist-get cache-entry :extra-term-sources) #'string<))
new-term-hash new-term-extras)
(org-glossary--get-terms-cached path-spec t nil)
(setq cache-entry (cdr (assoc path-spec org-glossary--terms-cache))
new-term-hash (plist-get cache-entry :terms-hash)
new-term-extras (sort (plist-get cache-entry :extra-term-sources)
(unless (and (eq old-term-hash new-term-hash)
(equal old-term-extras new-term-extras))
(when org-glossary-mode (org-glossary-update-terms))
(org-glossary--propagate-buffer-updates path-spec)))))
(defun org-glossary--update-buffers ()
"Update all live buffers in `org-glossary--paths-to-update'."
(dolist (path-spec org-glossary--paths-to-update)
(when-let ((buf (or (and (buffer-live-p path-spec) path-spec)
(get-file-buffer (plist-get path-spec :file)))))
(with-current-buffer buf
(when org-glossary-mode
(when org-glossary--path-update-timer
(cancel-timer org-glossary--path-update-timer)
(setq org-glossary--path-update-timer nil))
(setq org-glossary--paths-to-update nil))
;;; Export, general functionality
(defvar-local org-glossary--current-export-spec nil)
(defun org-glossary--get-export-specs (backend)
"Determine the relevant export specs for BACKEND.
The information is extracted from `org-glossary-export-specs'."
(let* ((default-spec (alist-get t org-glossary-export-specs))
(cl-loop for back = (if (symbolp backend)
(org-export-get-backend backend)
then (org-export-get-backend
unless back return default-spec
for spec = (alist-get (org-export-backend-name back)
when spec return spec))
(org-combine-plists (alist-get t default-spec)
(alist-get t current-spec)))
(lambda (type)
(let ((template
(alist-get type default-spec)
(alist-get type current-spec))))
(cons type template)))))
(cons (cons t default-template)
(mapcar complete-template
(mapcar #'cdr org-glossary-headings)))))
(defun org-glossary-set-export-spec (backend type &rest property-value-pairs)
"For the TYPE plist for BACKEND's export spec, set PROPERTY-VALUE-PAIRS.
Specifically, for each :PROPERTY VALUE pair of PROPERTY-VALUE-PAIRS, that
PROPERTY is set to VALUE within the TYPE list of the BACKEND list in
(declare (indent 2))
(while property-value-pairs
(setf (alist-get type (alist-get backend org-glossary-export-specs))
(plist-put (alist-get type (alist-get backend org-glossary-export-specs))
(pop property-value-pairs) (pop property-value-pairs)))))
(defun org-glossary--export-instance (backend info term-entry form &optional ref-index plural-p capitalized-p extra-parameters)
"Export the FORM of TERM-ENTRY according to `org-glossary--current-export-spec'.
CAPITALIZED-P, EXTRA-PARAMETERS) are simply passed onto the
relevant template."
(let ((template
(plist-get (alist-get
(plist-get term-entry :type)
((stringp template)
template backend info term-entry
ref-index plural-p capitalized-p extra-parameters))
((functionp template)
(funcall template backend info term-entry form
ref-index plural-p capitalized-p extra-parameters))
((not template) "")
(defun org-glossary--export-template (template backend info term-entry &optional ref-index plural-p capitalized-p extra-parameters)
The fields availible to the template are further affected by the
optional arguments:
- REF-INDEX provides %r
- PLURAL-P and CAPITALIZED-P affect %t and %v
- EXTRA-PARAMETERS defines additional fields"
(let ((parameters (reverse extra-parameters))
(canonical-term (or (plist-get term-entry :alias-for) term-entry))
(when org-glossary-canonicalise-aliases
(setq term-entry canonical-term))
(when (string-match-p "%k" template)
(push (cons ?k (plist-get canonical-term :key)) parameters))
(when (string-match-p "%K" template)
(push (cons ?K (number-to-string (plist-get canonical-term :key-nonce)))
(when (string-match-p "%t" template)
(push (cons ?t (funcall (if capitalized-p #'org-glossary--sentance-case
(plist-get term-entry
(if plural-p :term-plural :term))))
(when (string-match-p "%l" template)
(push (cons ?l (string (downcase (aref (plist-get term-entry :term) 0))))
(when (string-match-p "%L" template)
(push (cons ?L (string (upcase (aref (plist-get term-entry :term) 0))))
(when (and (not (memq ?v (mapcar #'car extra-parameters)))
(string-match-p "%v" template))
(push (cons ?v
(let ((value-str
(org-glossary--export-term canonical-term info)))
(funcall (if capitalized-p #'org-glossary--sentance-case
(if plural-p
(let ((components (split-string value-str)))
(setf (car (last components))
(funcall org-glossary-plural-function
(car (last components))))
(mapconcat #'identity components " "))
(when (and ref-index (string-match-p "%r" template))
(push (cons ?r (number-to-string ref-index))
(when (string-match-p "%n" template)
(push (cons ?n (number-to-string
(length (plist-get term-entry :uses))))
(when (string-match-p "%c" template)
(push (cons ?c (plist-get canonical-term :category))
(when (string-match-p "%u" template)
(push (cons ?u (org-glossary--export-instance
backend info term-entry :use
ref-index plural-p capitalized-p
(format-spec template (nreverse parameters))))
(defun org-glossary--export-term (term-entry info)
"When TERM-ENTRY's :value is non-nil, return the exported value using INFO.
If the :value has not been exported before, `org-export-filter-apply-functions'
is applied to the :value first, and the result is cached.
Should :value consist of a single paragraph, its contents are
exported in place of the paragraph itself."
(when-let ((value (plist-get term-entry :value)))
(or (gethash value (plist-get info :exported-data))
(puthash value
(let ((filtered-value
(plist-get info :filter-parse-tree)
value info)))
(if (and (= (length filtered-value) 1)
(eq (org-element-type (car filtered-value))
(mapcar #'org-element-extract-element
(org-element-contents (car filtered-value)))
(plist-get info :exported-data)))))
(defun org-glossary--sentance-case (s)
"Return a sentance-cased version of S."
(concat (string (upcase (aref s 0))) (substring s 1)))
;;; Export used term definitions
(defun org-glossary--print-terms (backend terms &optional types level duplicate-mentions)
"Produce an org-mode AST defining TERMS for BACKEND.
Do this for each of TYPES (by default, the :type specified in
`org-glossary-default-print-parameters'),producing a heading of level
LEVEL (by default: 1). If LEVEL is set to 0, no heading is produced.
Unless duplicate-mentions is non-nil, terms already defined will be excluded."
(let ((terms-by-type
(lambda (trm)
(or (and (not (plist-get trm :uses)) ; Occurs when `trm' is an alias.
(and (not duplicate-mentions)
(plist-get trm :extracted))))
:key #'org-glossary--string>)
(lambda (trm) (plist-get trm :type))
(or types (plist-get org-glossary-default-print-parameters :type))))
(level (or level 1))
export-spec content)
(lambda (type-terms)
(setq export-spec (alist-get (car type-terms) org-glossary--current-export-spec)
content (org-glossary--print-terms-by-category
backend (car type-terms) (cdr type-terms) level))
(if (and (not (string-empty-p (plist-get export-spec :heading)))
(> level 0))
(and (string-match-p "^\\* " (plist-get export-spec :heading))
(make-string (1- level) ?*))
(plist-get export-spec :heading)
(defun org-glossary--print-terms-by-category (backend type terms level)
"Produce a string printing TERMS for TYPE in BACKEND split by category."
(let ((terms-by-category
(org-glossary--sort-plist terms :key #'org-glossary--string>)
(lambda (trm) (plist-get trm :category))))
(export-spec (alist-get type org-glossary--current-export-spec))
content cat-heading)
(if (= (length terms-by-category) 1)
backend type terms (+ level (if (caar terms-by-category) 1 0)))
(lambda (cat-terms)
(setq content (org-glossary--print-terms-by-letter
backend type (cdr cat-terms)
(+ level (if (cl-some #'car terms-by-category) 1 0)))
cat-heading (org-glossary--export-instance
backend nil (cadr cat-terms) :category-heading))
(if (not (string-empty-p (plist-get export-spec :category-heading)))
(if (string-match-p "^\\* " cat-heading)
(concat (make-string level ?*) cat-heading)
(defun org-glossary--print-terms-by-letter (backend type terms level)
"Produce an org-mode AST for TYPE in BACKEND defining ASSEMBLED-TERMS."
(let* ((terms-by-letter
(org-glossary--sort-plist terms :key #'org-glossary--string>)
(lambda (trm) (upcase (aref (plist-get trm :key) 0)))))
(num-terms-by-letter (mapcar (lambda (trms) (length (cdr trms)))
(export-spec (alist-get type org-glossary--current-export-spec))
(and (>= (apply #'+ num-terms-by-letter)
(car org-glossary-print-letter-minimums))
(>= (apply #'max num-terms-by-letter)
(cdr org-glossary-print-letter-minimums))
(not (string-empty-p (plist-get export-spec :letter-heading))))))
(and (not use-letters-p)
(not (string-empty-p (plist-get export-spec :definition-structure-preamble)))
(concat (plist-get export-spec :definition-structure-preamble) "\n"))
(lambda (letter-terms)
(let ((letter-heading
(if use-letters-p
backend nil (cadr letter-terms) :letter-heading)
(and (not (string-empty-p letter-heading))
(if (string-match-p "^\\* " letter-heading)
(concat (make-string level ?*) letter-heading)
(and (not (string= "" (plist-get export-spec :definition-structure-preamble)))
(concat (plist-get export-spec :definition-structure-preamble) "\n"))))
(lambda (term-entry)
(org-glossary--print-terms-singular backend term-entry))
(cdr letter-terms)
(defun org-glossary--print-terms-singular (backend term-entry)
backend nil term-entry :definition-structure
nil nil nil
(let ((key (plist-get term-entry :key)))
(if (plist-get term-entry :alias-for)
`((?d . ,(format "[[glsdef:%s]]" key))
(?v . ,(org-glossary--export-instance
backend nil (plist-get term-entry :alias-for)
:alias-value 0))
(?b . ,""))
`((?d . ,(format "[[glsdef:%s]]" key))
(?v . ,(string-trim (org-element-interpret-data
(plist-get term-entry :value))))
(?b . ,(mapconcat
(lambda (use)
(format "[[glsuse:%d:%s]]" (car use) key))
(plist-get term-entry :uses)
#'< :key #'car)
backend nil term-entry :backref-seperator))))))))
(defun org-glossary--group-terms (terms predicate &optional include)
"Group TERMS according to PREDICATE, and optionaly only INCLUDE certain groups."
(let (groups grp)
(dolist (trm terms)
(setq grp (funcall predicate trm))
(push trm
(cdr (or (assoc grp groups)
(car (push (cons grp nil)
(lambda (group-terms)
(cons (car group-terms)
(nreverse (cdr group-terms))))
(if include
(lambda (group-terms)
(and include
(not (member (car group-terms)
(defun org-glossary--sort-plist (plist key predicate)
"Sort PLIST by KEY according to PREDICATE."
(sort plist
(lambda (a b)
(funcall predicate
(plist-get a key)
(plist-get b key)))))
(defun org-glossary--string> (a b)
"Check if A > B using collation order, ignoring case."
(and (not (string= a b))
(not (string-collate-lessp a b nil t))))
(defun org-glossary--strip-headings (&optional data _backend info remove-from-buffer)
"Remove glossary headlines."
(let ((data (or data (org-element-parse-buffer)))
(lambda (heading)
(when (org-glossary--definition-heading-p heading)
(if remove-from-buffer
(push (list (org-element-property :begin heading)
(org-element-property :end heading))
(org-element-extract-element heading))))
(and org-glossary-toplevel-only 'headline))
(lambda (region)
(apply #'delete-region region))
(defun org-glossary--extract-uses-in-region (terms begin end &optional types mark-extracted)
"Extract uses of TERMS that occur between BEGIN and END.
If TYPES is non-nil, extracted entries shall be restricted instances of TYPES.
If MARK-EXTRACTED is non-nil, extracted uses shall be marked as extracted."
(let (region-terms region-term-uses)
(lambda (term-entry)
(when (or (not types)
(memq (plist-get term-entry :type) types))
(setq region-term-uses nil)
(dolist (use (plist-get term-entry :uses))
(when (and (<= begin
(org-element-property :begin (cdr use))
(org-element-property :end (cdr use))
(push use region-term-uses)
(plist-put (cdr use) :extracted t)))
(when region-term-uses
(push (org-combine-plists
term-entry (list :uses region-term-uses))
(nreverse region-terms)))
(defun org-glossary--expand-print (backend terms &optional parameters)
"Generate string defining TERMS in BACKEND, according to PARAMETERS."
backend terms
(plist-get parameters :types)
(if (plist-get parameters :only-contents)
(1+ (plist-get parameters :level)))
(plist-get parameters :all)))
(defun org-glossary--expand-print-keyword (backend terms keyword)
"Call `org-glossary--expand-print' with paramaters and terms based on KEYWORD.
BACKEND is passed through unmodified, but TERMS may be modified depending on
the :consume parameter extracted from KEYWORD."
(let ((heading (org-element-lineage keyword '(headline org-data)))
(parameters (org-combine-plists
(org-element-property :value keyword)))))
(while (and (not (eq (org-element-type heading) 'org-data))
(> (org-element-property :level heading)
(plist-get parameters :level)))
(setq heading (org-element-lineage heading '(headline org-data))))
(org-element-property :begin heading)
(org-element-property :end heading)
(plist-get parameters :type)
(plist-get parameters :consume))
(defun org-glossary--parse-print-keyword-value (value)
"Parse the string VALUE to a parameter plist."
(let ((res '()))
(dolist (pair (org-babel-parse-header-arguments value))
(push (car pair) res)
(pcase (car pair)
(:type (mapcar #'intern (split-string (cdr pair))))
((or :consume :only-contents :all)
(and (stringp (cdr pair))
(or (string= "t" (cdr pair))
(string= "yes" (cdr pair)))))
(_ (cdr pair)))
(nreverse res)))
;;; Link definitions
(org-link-set-parameters "gls"
:export #'org-glossary--link-export-gls
:face 'org-glossary-term
:follow #'org-glossary-goto-term-definition
:help-echo #'org-glossary--help-echo-from-textprop)
(org-link-set-parameters "glspl"
:export #'org-glossary--link-export-glspl
:face 'org-glossary-term
:follow #'org-glossary-goto-term-definition
:help-echo #'org-glossary--help-echo-from-textprop)
(org-link-set-parameters "Gls"
:export #'org-glossary--link-export-Gls
:face 'org-glossary-term
:follow #'org-glossary-goto-term-definition
:help-echo #'org-glossary--help-echo-from-textprop)
(org-link-set-parameters "Glspl"
:export #'org-glossary--link-export-Glspl
:face 'org-glossary-term
:follow #'org-glossary-goto-term-definition
:help-echo #'org-glossary--help-echo-from-textprop)
(defun org-glossary--link-export-gls (index-term description backend info)
"Export a gls link to term index-term with BACKEND."
(org-glossary--link-export backend info index-term description nil nil))
(defun org-glossary--link-export-glspl (index-term description backend info)
"Export a glspl link to term index-term with BACKEND."
(org-glossary--link-export backend info index-term description t nil))
(defun org-glossary--link-export-Gls (index-term description backend info)
"Export a Gls link to term index-term with BACKEND."
(org-glossary--link-export backend info index-term description nil t))
(defun org-glossary--link-export-Glspl (index-term description backend info)
"Export a Glspl link to term index-term with BACKEND."
(org-glossary--link-export backend info index-term description t t))
(defconst org-glossary--index-stub-description
"Usage description value that should be treated as an invisible reference.")
(defun org-glossary--link-export (backend info index-term description &optional plural-p capitalized-p)
"Export a link to TERM with BACKEND, respecting PLURAL-P and CAPITALIZED-P."
(if-let ((index (if (seq-contains-p index-term ?:)
(string-to-number (car (split-string index-term ":")))
(trm (replace-regexp-in-string "^.+?:" "" index-term))
(term-entry (org-glossary--quicklookup trm)))
backend info term-entry (if (= 1 index) :first-use :use)
index plural-p capitalized-p
(and (stringp description)
`((?t . ,(if (string= description org-glossary--index-stub-description)
"" description)))))
(funcall (if capitalized-p #'org-glossary--sentance-case #'identity)
(funcall (if plural-p org-glossary-plural-function #'identity)
(org-link-set-parameters "glsdef"
:export #'org-glossary--link-export-glsdef
:face 'org-glossary-term)
(org-link-set-parameters "glsuse"
:export #'org-glossary--link-export-glsuse
:face 'org-glossary-term)
(defun org-glossary--link-export-glsdef (key _ backend info)
(if-let ((term-entry (org-glossary--quicklookup key)))
(let ((capitalised-term
(concat (upcase (substring (plist-get term-entry :term) 0 1))
(substring (plist-get term-entry :term) 1))))
backend info term-entry :definition nil nil nil
`((?t . ,capitalised-term)
(?k . ,(plist-get term-entry :key))
(?K . ,(number-to-string (plist-get term-entry :key-nonce))))))
(defun org-glossary--link-export-glsuse (index-term _desc backend info)
(if-let ((index (if (seq-contains-p index-term ?:)
(string-to-number (car (split-string index-term ":")))
(trm (replace-regexp-in-string "^.+?:" "" index-term))
(term-entry (org-glossary--quicklookup trm)))
(org-glossary--export-instance backend info term-entry :backref index)
;;; Pluralisation
(defcustom org-glossary-english-plural-exceptions nil
"An alist of (lowercase) words and their plural forms.
For inspiration, see"
:type '(alist :key-type (string :tag "singular")
:value-type (string :tag "plural")))
(defun org-glossary-english-plural (word)
"Generate the plural form of WORD."
(or (let ((plural
(alist-get (if (string-match-p "^[[:upper]]+$" word)
(downcase word) word)
nil nil #'string=))
(when plural
((string-match-p "^[[:lower:]]+$" word) plural)
((string-match-p "^[[:upper:]][[:lower:]]+$" word)
(capitalize plural))
((string-match-p "^[[:upper:]]+$" word) (upcase plural)))))
;; Source:
((let (case-fold-search) ; Acronyms shouldn't be treated as words.
(string-match-p "^[[:upper:]]+$" word))
(concat word "s"))
((string-match "m[ae]n$" word)
(replace-match "men" nil t word))
((string-match-p "eaux$" word) word)
((string-match "\\(child\\)\\(?:ren\\)?$" word)
(replace-match "\\1ren" nil nil word))
((string-match "pe\\(?:rson\\|ople\\)$" word)
(replace-match "people" nil t word))
((string-match "\\b\\(\\(?:tit\\)?m\\|l\\)\\(?:ice\\|ouse\\)$" word)
(replace-match "\\1ice" nil nil word))
((string-match "\\(matr\\|cod\\|mur\\|sil\\|vert\\|ind\\|append\\)\\(?:ix\\|ex\\)$" word)
(replace-match "\\1ices" nil nil word))
((string-match "\\(x\\|ch\\|ss\\|sh\\|zz\\)$" word)
(replace-match "\\1es" nil nil word))
((string-match "\\([^ch][ieo][ln]\\)ey$" word)
(replace-match "\\1es" nil nil word))
((string-match "\\([^aeiou]\\|qu\\)y$" word)
(replace-match "\\1ies" nil nil word))
((string-match "\\(?:\\(kni\\|wi\\|li\\)fe\\|\\(ar\\|l\\|ea\\|eo\\|oa\\|hoo\\)f\\)$" word)
(replace-match "\\1\\2ves" nil nil word))
((string-match "sis$" word)
(replace-match "ses" nil nil word))
((string-match "\\(apheli\\|hyperbat\\|periheli\\|asyndet\\|noumen\\|phenomen\\|criteri\\|organ\\|prolegomen\\|hedr\\|automat\\)\\(?:a\\|on\\)$" word)
(replace-match "\\1a" nil nil word))
((string-match "\\(agend\\|addend\\|millenni\\|dat\\|extrem\\|bacteri\\|desiderat\\|strat\\|candelabr\\|errat\\|ov\\|symposi\\|curricul\\|automat\\|quor\\)\\(?:a\\|um\\)$" word)
(replace-match "\\1a" nil nil word))
((string-match "\\(her\\|at\\|gr\\)o$" word)
(replace-match "\\1oes" nil nil word))
((string-match "\\(seraph\\|cherub\\)\\(?:im\\)?$" word)
(replace-match "\\1im" nil nil word))
((string-match "\\(alumn\\|alg\\|vertebr\\)\\(?:a\\|ae\\)$" word)
(replace-match "\\1ae" nil nil word))
((string-match "\\(alumn\\|syllab\\|vir\\|radi\\|nucle\\|fung\\|cact\\|stimul\\|termin\\|bacill\\|foc\\|uter\\|loc\\|strat\\)\\(?:us\\|i\\)$" word)
(replace-match "\\1i" nil nil word))
((string-match-p "\\([^l]ias\\|[aeiou]las\\|[ejzr]as\\|[iu]am\\)$" word) word)
((string-match "\\(e[mn]u\\)s?$" word)
(replace-match "\\1s" nil nil word))
((string-match "\\(i\\|l\\)um$" word) ; added
(replace-match "\\1a" nil nil word))
((string-match "\\(alias\\|[^aou]us\\|t[lm]as\\|gas\\|ris\\)$" word)
(replace-match "\\1es" nil nil word))
((string-match "\\(ax\\|test\\)is$" word)
(replace-match "\\1es" nil nil word))
((string-match "enon$" word)
(replace-match "ena" nil nil word))
((string-match-p "\\([^aeiou]ese\\)$" word) word)
(t (concat word "s")))))
;;; Export
(defun org-glossary--prepare-buffer (&optional backend)
"Modify the buffer to resolve all defined terms, prepearing it for export.
This should only be run as an export hook."
(setq org-glossary--terms (org-glossary--get-terms-cached)
(org-glossary--get-export-specs backend))
(org-glossary--strip-headings nil nil nil t)
(let ((index-terms-mrx (org-glossary--mrx-construct-from-terms
(lambda (trm)
(not (eq (plist-get trm :type) 'index)))
(goto-char (point-min))
(while (org-glossary--mrx-search-forward index-terms-mrx)
(when (save-match-data
(looking-back "^[ \t]*#\\+[cfkptv]?index:[ \t]*"
(format "[[gls:\\1][%s]]"
(let ((used-terms (org-glossary-apply-terms org-glossary--terms))
keyword print-glossary-p)
(goto-char (point-min))
(while (re-search-forward "^[ \t]*#\\+print_glossary:" nil t)
(setq print-glossary-p t
keyword (org-element-context))
(delete-region (org-element-property :begin keyword)
(- (org-element-property :end keyword)
(org-element-property :post-blank keyword)
(insert (org-glossary--expand-print-keyword backend used-terms keyword)))
(unless print-glossary-p
(goto-char (point-max))
(insert "\n" (org-glossary--print-terms backend used-terms))))))
(add-hook 'org-export-before-parsing-functions #'org-glossary--prepare-buffer)
;;; Fontification
(defvar-local org-glossary--term-mrx nil
"A multi-rx matching all known forms of terms.")
(defvar-local org-glossary--font-lock-keywords
(0 (org-glossary--fontify-term))))
"`font-lock-keywords' entry that fontifies term references.")
(defun org-glossary--set-font-lock-keywords (&optional per-term-p)
"Set `org-glossary--font-lock-keywords' according to PER-TERM-P."
(setq org-glossary--font-lock-keywords
(if per-term-p
(0 (org-glossary--fontify-term))))
(0 '(face org-glossary-term
help-echo org-glossary--help-echo-from-textprop
mouse-face (:inverse-video t)
keymap (keymap
(follow-link . mouse-face)
(mouse-2 . org-glossary-goto-term-definition)
("RET" . org-glossary-goto-term-definition)
(return . org-glossary-goto-term-definition)))
(define-minor-mode org-glossary-mode
"Glossary term fontification, and enhanced interaction."
:global nil
:group 'org-glossary
((and org-glossary-mode org-glossary-automatic)
(org-glossary--set-font-lock-keywords org-glossary-fontify-types-differently)
(font-lock-add-keywords nil org-glossary--font-lock-keywords 'append))
(t (font-lock-remove-keywords nil org-glossary--font-lock-keywords)
(org-with-wide-buffer (font-lock-flush))))
(if org-glossary-mode
(add-hook 'after-save-hook
'org-glossary--detect-updates-and-propagate nil t)
(add-hook 'kill-buffer-hook
'org-glossary--deregister-buffer-dependencies nil t))
(remove-hook 'kill-buffer-hook
'org-glossary--deregister-buffer-dependencies t))
(defun org-glossary--fontify-find-next (&optional limit)
"Find any next occurance of a term reference, for fontification."
(let ((search-spaces-regexp (and font-lock-multiline "[ \t\n][ \t]*"))
match-p exit element-at-point element-context)
(while (and (not exit) (if limit (< (point) limit) t))
(setq exit (null (org-glossary--mrx-search-forward
org-glossary--term-mrx limit)))
(setq element-at-point (org-element-at-point)
element-context (org-element-context element-at-point))
(when (and (not exit)
(not (eq 'headline (org-element-type element-at-point)))
(memq 'link (org-element-restriction element-context))
(if (eq 'keyword (org-element-type element-at-point))
(member (org-element-property :key element-at-point)
(not (org-glossary--within-definition-p element-context)))
;; HACK For some strange reason, if I don't move point forwards
;; here, this function will end up being called again and again
;; ad-infinitum. Strangely, while (forward-char 1) works
;; (goto-char (match-end 0)) does not. What on earth is happening?
;; Please send help.
(forward-char 1)
(setq exit t match-p t))))
(defvar-local org-glossary--fontified-snippet-cache
(make-hash-table :test #'equal))
(defun org-glossary--fontify-org-snippet (org-text)
"Fontify the string ORG-TEXT using `org-mode'."
(or (gethash org-text org-glossary--fontified-snippet-cache)
(puthash org-text
(insert org-text)
(let (org-mode-hook org-glossary-snippet-fontication-hooks)
(defun org-glossary--fontify-term ()
"Fontify the matched term."
(let ((term-entry (org-glossary--quicklookup (match-string 0)))
(match-beginning 0) (match-end 0)
(pcase (plist-get term-entry :type)
(if org-glossary-display-substitute-value
`(face org-glossary-substituted-value
help-echo org-glossary--help-echo-from-textprop
mouse-face org-glossary-substitution-term
(if org-glossary-fontify-displayed-substitute
#'org-glossary--fontify-org-snippet #'identity)
(if (string-match-p "^[[:upper:]][^[:upper:]]+$"
(match-string 0))
#'org-glossary--sentance-case #'identity)
(plist-get term-entry :value)))))))
'(face org-glossary-substitution-term)))
(type `(face ,(or (alist-get type org-glossary-fontify-type-faces)
mouse-face (:inverse-video t)
keymap (keymap
(follow-link . mouse-face)
(mouse-2 . org-glossary-goto-term-definition)
("RET" . org-glossary-goto-term-definition)
(return . org-glossary-goto-term-definition)))))))
(defvar-local org-glossary--help-echo-cache (make-hash-table :test #'equal)
"A hash table for quickly looking up fontified help-echo strings.")
(defun org-glossary--term-help-echo-str (term-entry)
(let ((org-inhibit-startup t)
(plist-get term-entry :value))))
(when (looking-back "^[ \t]*#\\+end" (line-beginning-position))
(insert "\n"))
org-link-any-re "\\3" (buffer-string)))))
(defun org-glossary--term-help-echo-str-cached (term-entry)
(or (gethash term-entry org-glossary--help-echo-cache)
(puthash term-entry (org-glossary--term-help-echo-str term-entry)
(defun org-glossary--term-help-echo (term-entry &optional no-squash)
"Generate a help-echo string for TERM-ENTRY."
(let* ((referenced-term
(or (plist-get term-entry :alias-for)
(string-trim (substring-no-properties
(plist-get term-entry :value)))))))
(or referenced-term term-entry))))
(format "(%s) %s %s"
(symbol-name (plist-get (or referenced-term term-entry) :type))
'face 'org-table)
(plist-get term-entry :term)
'face (if referenced-term 'font-lock-doc-face 'org-list-dt))
(and referenced-term
(plist-get referenced-term :term)
'face 'org-list-dt))))
(if no-squash
"\s?\n\s*" " " ; flatten newline indentation
(defun org-glossary--help-echo-from-textprop (_window object pos &optional no-squash)
"Find the term reference at POS in OBJECT, and get the definition."
(let ((term-entry
(with-current-buffer object
"^[Gg]ls\\(?:pl\\)?:" ""
(or (previous-single-property-change (1+ pos) 'face) (point-min))
(or (next-single-property-change pos 'face) (point-max))))))))
(and term-entry (org-glossary--term-help-echo term-entry no-squash))))
;;; Completion
;; The sole purpose of `org-options-keywords' is to supply candidates
;; in org-pcomplete.el, so we may as well add our new keywords to the list.
(add-to-list 'org-options-keywords "GLOSSARY_SOURCES:")
(add-to-list 'org-options-keywords "PRINT_GLOSSARY:")
;;; Interaction
(defvar-local org-glossary--quicklookup-cache (make-hash-table :test #'equal)
"A hash table for quickly looking up a term-entry from a reference form.")
(defun org-glossary--quicklookup (term-str)
"Find the term entry reffered to by TERM-STR."
(or (gethash term-str org-glossary--quicklookup-cache)
(and (not (string-empty-p term-str))
(let ((term-entry
(or (org-glossary--find-term-entry
org-glossary--terms term-str :key)
org-glossary--terms term-str :key-plural)
(concat (string (downcase (aref term-str 0)))
(substring term-str 1))
(concat (string (downcase (aref term-str 0)))
(substring term-str 1))
(puthash term-str term-entry
(defun org-glossary-update-terms (&optional show-info)
"Update the currently known terms."
(interactive "p")
(unless (derived-mode-p 'org-mode)
(user-error "You need to be using `org-mode' to use org-glossary."))
(let ((initial-terms (mapcar (lambda (trm) (plist-get trm :term))
(setq org-glossary--extra-term-sources (org-glossary--get-extra-term-sources)
org-glossary--terms (org-glossary--get-terms-cached)
(org-glossary--mrx-construct-from-terms org-glossary--terms)
org-glossary--quicklookup-cache (make-hash-table :test #'equal)
org-glossary--help-echo-cache (make-hash-table :test #'equal))
(when show-info
(mapcar (lambda (trm) (plist-get trm :term))
(when org-glossary-mode
(defun org-glossary--get-extra-term-sources (&optional parse-tree)
"Identify all applicable sources of extra terms for the current buffer.
This combines locations listed in `org-glossary-global-terms' with
local sources specified with \"#+glossary_sources: LOCATIONS\".
LOCATIONS is interpreted as space-seperated path specification
components which are prefixed by `org-glossary-collection-root'.
Path spec components including spaces can be given by enclosing
the location in double quotes. Should a file exist at a resolved
location with the extension .org, that file will be used as the
(lambda (source-short)
(let ((fq-source (concat org-glossary-collection-root source-short)))
((file-exists-p (concat fq-source ".org"))
(concat fq-source ".org"))
((string-match-p "\\.org::[*#]." fq-source)
(concat fq-source " :only-contents t"))
(t fq-source))))
(or (mapconcat
(org-element-map (or parse-tree (org-element-parse-buffer)) 'keyword
(lambda (keyword)
(and (equal "GLOSSARY_SOURCES" (org-element-property :key keyword))
(org-element-property :value keyword))))
" ")
(defun org-glossary--term-status-message (current-terms &optional initial-terms)
"Emit a status mesage, based on CURRENT-TERMS and INITIAL-TERMS."
(let ((added-terms (cl-set-difference current-terms initial-terms :test #'string=))
(removed-terms (cl-set-difference initial-terms current-terms :test #'string=))
(n-sources (length (cl-delete-duplicates
(lambda (term-entry)
(plist-get term-entry :definition-file))
(message "%s"
(propertize "org-glossary" 'face 'bold)
": "
(propertize (number-to-string (length current-terms))
'face 'warning)
" registered term" (and (> (length current-terms) 1) "s")
" from "
(propertize (number-to-string n-sources)
'face 'bold)
" source" (and (> n-sources 1) "s")
(and (or added-terms removed-terms) ", ")
(and added-terms
(format "%s term%s added"
(propertize (number-to-string (length added-terms))
'face 'success)
(if (> (length added-terms) 1) "s" "")))
(and added-terms removed-terms ", ")
(and removed-terms
(format "%s term%s removed"
(propertize (number-to-string (length removed-terms))
'face 'error)
(if (> (length removed-terms) 1) "s" "")))))))
(defun org-glossary--select-term (terms)
"Select a term entry from TERMS."
(let* ((term-text (mapcar #'org-glossary--select-term-candidatify terms))
"Term: "
(lambda (string predicate action)
(if (eq action 'metadata)
(annotation-function . org-glossary--select-term-annotation)
(group-function . org-glossary--select-term-group)
(category . glossary-entry))
(complete-with-action action term-text string predicate))))))
terms (car (split-string choice "\u200b")) :term)))
(defun org-glossary--select-term-candidatify (term-entry)
"Create a term string from TERM-ENTRY with itself attached as a text property."
(plist-get term-entry :term)
(make-string (max 0 (- 18 (length (plist-get term-entry :term)))) ?\s))
'face 'font-lock-keyword-face
'org-glossary--term term-entry))
(defun org-glossary--select-term-annotation (term-text)
"Construct the annotation for TERM-TEXT.
Where TERM-TEXT is constructed by `org-glossary--select-term-candidatify'."
(concat " "
(unless org-glossary-group-ui
(org-glossary--select-term-group term-text nil)
9 0 ?\s))
"\n\s*" " "
(get-text-property 0 'org-glossary--term term-text)
(defun org-glossary--select-term-group (term-text transform)
"Construct the group of TERM-TEXT.
Where TERM-TEXT is constructed by `org-glossary--select-term-candidatify'."
(if transform term-text
(get-text-property 0 'org-glossary--term term-text)
(defun org-glossary-goto-term-definition (&optional term-ref)
"Go to the definition of TERM-REF.
TERM-REF may be a string, position in the buffer to look for a
term, or a term entry list. If TERM-REF is not given, the current
point will be used."
(when-let ((term-entry
(if (consp term-ref) term-ref
(or (org-glossary--quicklookup
(or (and (stringp term-ref) term-ref)
"^[Gg]ls\\(?:pl\\)?:" ""
(or (previous-single-property-change
(1+ (or (and (numberp term-ref) term-ref) (point))) 'face)
(or (next-single-property-change
(or (and (numberp term-ref) term-ref) (point)) 'face)
(org-glossary--select-term org-glossary--terms)))))
(if-let ((aliased-term
(string-trim (org-element-interpret-data
(plist-get term-entry :value))))))
(setq term-entry aliased-term))
(let ((defsource (plist-get term-entry :definition-file)))
(or (and (bufferp defsource) defsource)
(get-file-buffer defsource)
(find-file defsource))))
(goto-char (plist-get term-entry :definition-pos))
(defun org-glossary-list-duplicates ()
"Examine the currently defined terms, showing duplications."
(if-let ((duplicated-terms
(org-glossary--identify-duplicates org-glossary--terms)))
(let ((orig-buf (current-buffer))
(buf (get-buffer-create "*Org Glossary Duplicate Terms*")))
(with-current-buffer buf
(insert (propertize (format "%d duplicated terms found"
(length duplicated-terms))
'face 'org-level-2)
(dolist (term-duplicates duplicated-terms)
(let ((term (car term-duplicates))
(duplicates (cdr term-duplicates))
(i 0))
(insert (propertize (format "Duplicates for %s" term)
'face 'org-level-3)
(dolist (dup duplicates)
(insert (propertize (format " %d. " (setq i (1+ i)))
'face '(bold org-list-dt))
(if (equal term (plist-get dup :key))
(propertize term 'face 'org-list-dt)
(propertize (plist-get dup :key) 'face 'shadow))
(and (plist-get dup :key-plural) "/")
(and (plist-get dup :key-plural)
(if (equal term (plist-get dup :key-plural))
(propertize term 'face 'org-list-dt)
(propertize (plist-get dup :key-plural)
'face 'shadow))))
" from ")
(format "%s@%d"
(file-name-nondirectory (plist-get dup :definition-file))
(plist-get dup :definition-pos))
'face 'link
'action (lambda (_button)
(if (bufferp (plist-get dup :definition-file))
(select-window (display-buffer (plist-get dup :definition-file)
'(nil (inhibit-same-window t))))
(other-window 1))
(with-current-buffer orig-buf
(org-glossary-goto-term-definition dup)))
'help-echo "mouse-2, RET: Go to this definition"
'follow-link t)
(insert "\n "
"\n *" " "
(string-trim (org-element-interpret-data
(plist-get dup :value))))
(min 120 (- (window-width) 4))
nil nil t)
'face 'font-lock-doc-face)
(insert "\n")))
(goto-char (point-min)))
(pop-to-buffer buf))
(message "No duplicate terms detected.")))
(defun org-glossary--identify-duplicates (terms)
"Identify duplicates in TERMS."
(let ((terms-seen (make-hash-table :size (length terms)
:test #'equal))
(dolist (term-entry terms)
(dolist (keystr (list (plist-get term-entry :key)
(plist-get term-entry :key-plural)))
(when keystr
(if (gethash keystr terms-seen)
(if (assoc keystr duplicated-terms)
(push term-entry
(alist-get keystr duplicated-terms nil nil #'equal))
(push (list keystr (gethash keystr terms-seen) term-entry)
(puthash keystr term-entry terms-seen)))))
(defun org-glossary-insert-term-reference ()
"Pick a term, and insert a reference to it."
(when-let ((term-entry (org-glossary--select-term org-glossary--terms)))
(insert (format (if org-glossary-automatic "%s" "[[gls:%s]]")
(plist-get term-entry :key)))))
(defun org-glossary-create-definition (term-str definition type &optional category)
"Add a entry for TERM-STR with DEFINITION, under TYPE and optionally CATEGORY."
;; This is a ugly long function, but I think it has to be this way.
(let* ((term-str
"Term: "
(or (and (region-active-p)
(thing-at-point 'word)
(definition (read-string "Definition: "))
"Type/category: "
(mapcar #'cdr org-glossary-headings)
nil nil
((and (string-match-p "^[[:upper:]]+$" term-str)
(string= term-str
"[^[:upper:]]" "" definition)))
((string-empty-p definition) "index")
((> (length (split-string definition)) 12) "glossary")))
(car (or (rassoc (intern (car type-category)) org-glossary-headings)
(assoc (car type-category) org-glossary-headings)
(car org-glossary-headings))))
(category (and (> (length type-category) 1) (cadr type-category))))
(list term-str definition type category)))
(unless (derived-mode-p 'org-mode)
(user-error "You need to be in `org-mode' to use org-glossary"))
(when (symbolp type)
(setq type (car (rassoc type org-glossary-headings))))
(let* ((type-sec-pattern
(format "^\\*%s %s\n" (if org-glossary-toplevel-only "" "+") type))
(unless (or (re-search-forward type-sec-pattern nil t)
(progn (goto-char (point-min))
(re-search-forward type-sec-pattern nil t)))
(goto-char (point-max))
(insert "\n* " type "\n"))
(- (match-end 0) (match-beginning 0) 1))
(org-forward-heading-same-level 1)
(if (= type-sec-begin (point))
(point-max) (1- (point)))))
(goto-char type-sec-begin)
(forward-char 1)
(or (and (re-search-forward "^\\*+ " nil t)
(forward-line -1)
(and category
(if (re-search-forward
(format "^\\*+ %s[ \t]+:%s:\n"
category org-glossary--category-heading-tag)
nil t)
(or (and (re-search-forward "^\\*+ " nil t)
(forward-line -1)
(goto-char type-sec-end)
(insert (make-string (1+ type-hlevel) ?*) " "
category " :" org-glossary--category-heading-tag ":\n")
(goto-char (or (and category category-sec-end) type-sec-nocat-end))
(re-search-backward "^[ \t]*[-+*] \\|^\\*")
(forward-line 1)
(if (and definition (not (string-empty-p definition)))
(insert (format "- %s :: %s" term-str definition) "\n")
(insert "- " term-str "\n")))))
;;; Eldoc
(defun org-glossary--eldoc-function (&rest _)
"Return help-echo output for org-glossary term at point.
This is intended as a :before-until advice for
(and org-glossary-mode
(eq (get-text-property (point) 'help-echo)
(org-glossary--help-echo-from-textprop nil (current-buffer) (point) t)))
(advice-add 'org-eldoc-documentation-function
:before-until 'org-glossary--eldoc-function)
(provide 'org-glossary)
;;; org-glossary.el ends here