denote-lint

a Emacs package to detect broken denote links
git clone https://git.trogloxene.org/denote-lint.git
Log | Files | Refs

commit effd72b4e436c28c7246f291dea144959ca31b38
Author: andsy10 <andsy1016@gmail.com>
Date:   Wed, 22 Jul 2026 07:23:19 +0800

Inistal commit

Diffstat:
Adenote-lint.el | 444+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 444 insertions(+), 0 deletions(-)

diff --git a/denote-lint.el b/denote-lint.el @@ -0,0 +1,444 @@ +;;; denote-lint.el --- Check for broken Denote links -*- lexical-binding: t; -*- + +;;; Commentary: + +;; This package provides commands to detect broken Denote links in Org +;; files under `denote-directory'. +;; +;; The supported link shapes are: +;; +;; - Plain file links: +;; [[denote:IDENTIFIER][description]] +;; +;; - Links to a heading by its text: +;; [[denote:IDENTIFIER::*heading text][description]] +;; +;; - Links to a heading by its CUSTOM_ID: +;; [[denote:IDENTIFIER::#h:UUID][description]] +;; +;; Links that use any other `::search' option are reported as +;; "unsupported" because the checker cannot reliably verify them. +;; +;; The main entry point is the command `denote-lint'. +;; +;; By default the scan runs asynchronously in a sub-process so that Emacs +;; stays responsive. Set `denote-lint-async-by-default' to nil to keep +;; the original synchronous behavior. + +;;; Code: + +(require 'denote) +(require 'org) +(require 'org-element) +(require 'url-util) +(require 'async) + +(defgroup denote-lint nil + "Check for broken Denote links." + :group 'denote) + +(defcustom denote-lint-report-buffer-name "*Denote Lint*" + "Name of the buffer used to display broken Denote links." + :type 'string + :group 'denote-lint) + +(defcustom denote-lint-grep-buffer-name "*Denote Lint Grep*" + "Name of the buffer used to display grep-style Denote lint results." + :type 'string + :group 'denote-lint) + +(defcustom denote-lint-async-by-default t + "When non-nil, run lint commands asynchronously. +Applies to `denote-lint', `denote-lint-current-buffer', +`denote-lint-grep', and `denote-lint-grep-current-buffer'." + :type 'boolean + :group 'denote-lint) + +(defvar denote-lint--async-process nil + "Process object of the currently running asynchronous lint, if any.") + +(defvar denote-lint--async-org-heading-regexp nil + "Captured `org-complex-heading-regexp' from the parent process. +This is set in async child processes to match the parent's Org +configuration, including custom `org-todo-keywords'.") + +(defun denote-lint--cancel-async () + "Cancel any running asynchronous lint process." + (when (and denote-lint--async-process + (process-live-p denote-lint--async-process)) + (kill-process denote-lint--async-process)) + (setq denote-lint--async-process nil)) + +(defun denote-lint--capture-org-heading-regexp () + "Return the current `org-complex-heading-regexp'. +Ensures an Org buffer exists so that the variable is initialized with +any custom `org-todo-keywords' in effect." + (with-temp-buffer + (org-mode) + org-complex-heading-regexp)) + +(defun denote-lint--async-setup-org-heading-regexp () + "Hook to use the captured parent heading regexp in temp Org buffers. +Applied to `org-mode-hook' by the async child process." + (when denote-lint--async-org-heading-regexp + (setq-local org-complex-heading-regexp denote-lint--async-org-heading-regexp))) + +(defcustom denote-lint-file-regexp ".*\\.org" + "Regexp to select source files to scan. +The regexp is matched against file names relative to `denote-directory'. +The default value limits the scan to Org files." + :type 'string + :group 'denote-lint) + +;;; Link extraction + +(defun denote-lint--query-link-p (target) + "Return non-nil if TARGET is a Denote query link. +Query links have the form `query-contents:...' or +`query-filenames:...' and are intentionally not checked." + (or (string-prefix-p "query-contents:" target) + (string-prefix-p "query-filenames:" target))) + +(defun denote-lint--extract-links (file) + "Extract all denote links from FILE. +Return a list of plists with keys :source-file, :line, :link-text, +:target and :description. Query links are excluded." + (with-temp-buffer + (insert-file-contents file) + (org-mode) + (let ((case-fold-search nil) + links) + (org-element-map (org-element-parse-buffer) 'link + (lambda (link) + (when (and (eq (org-element-property :format link) 'bracket) + (string= (org-element-property :type link) "denote")) + (let* ((begin (org-element-property :begin link)) + (link-end (org-element-property :end link)) + (target (org-element-property :path link)) + (description (when-let* ((contents-begin (org-element-property :contents-begin link)) + (contents-end (org-element-property :contents-end link))) + (buffer-substring-no-properties contents-begin contents-end))) + (link-text (buffer-substring-no-properties begin link-end))) + (unless (denote-lint--query-link-p target) + (push (list :source-file file + :line (line-number-at-pos begin) + :link-text link-text + :target target + :description description) + links)))))) + (nreverse links)))) + +;;; Heading verification + +(defun denote-lint--heading-text-exists-p (path heading-text) + "Return non-nil if PATH contains an Org heading equal to HEADING-TEXT." + (with-temp-buffer + (insert-file-contents path) + (org-mode) + (goto-char (point-min)) + (catch 'found + (while (re-search-forward org-complex-heading-regexp nil t) + (let ((title (match-string 4))) + (when (and title + (string= (string-trim-right title) heading-text)) + (throw 'found t)))) + nil))) + +(defun denote-lint--custom-id-exists-p (path custom-id) + "Return non-nil if PATH contains an Org entry with CUSTOM_ID equal to CUSTOM-ID." + (with-temp-buffer + (insert-file-contents path) + (org-mode) + (goto-char (point-min)) + (not (null (org-find-property "CUSTOM_ID" custom-id))))) + +(defun denote-lint--heading-link-valid-p (path search) + "Return non-nil if PATH contains the object specified by SEARCH. +SEARCH is the part after `::' in a denote link. It can be: + + - `*heading text' -> look for an Org heading with that text + - `#h:UUID' -> look for an Org CUSTOM_ID property + - anything else -> signal that the search is unsupported" + (cond + ((string-prefix-p "*" search) + (denote-lint--heading-text-exists-p path (substring search 1))) + ((string-prefix-p "#" search) + (denote-lint--custom-id-exists-p path (substring search 1))) + (t 'unsupported))) + +;;; Link validation + +(defun denote-lint--check-link (link) + "Check LINK and return it with an added :reason if it is broken. +LINK is a plist as produced by `denote-lint--extract-links'. Return +nil when the link is fine." + (let* ((target (plist-get link :target)) + (resolved (denote-link--ol-resolve-link-to-target target t)) + (path (nth 0 resolved)) + (query (nth 1 resolved)) + (file-search (nth 2 resolved))) + (cond + ((null path) + (plist-put (copy-sequence link) :reason + (list 'missing-file query))) + ((and file-search (not (string-empty-p file-search))) + (let ((valid-p (denote-lint--heading-link-valid-p path file-search))) + (cond + ((eq valid-p 'unsupported) + (plist-put (copy-sequence link) :reason + (list 'unsupported-search file-search))) + ((null valid-p) + (plist-put (copy-sequence link) :reason + (list 'missing-heading file-search))) + (t nil)))) + (t nil)))) + +(defun denote-lint--collect-broken-links (files) + "Scan FILES and return a list of broken denote links." + (let (broken) + (dolist (file files (nreverse broken)) + (dolist (link (denote-lint--extract-links file)) + (when-let* ((broken-link (denote-lint--check-link link))) + (push broken-link broken)))))) + +(defun denote-lint--reason-category (link) + "Return the reason category of a broken LINK. +Possible values are `missing-file', `missing-heading' and +`unsupported-search'." + (car (plist-get link :reason))) + +(defun denote-lint--reason-detail (link) + "Return the human-readable detail of a broken LINK's reason." + (cadr (plist-get link :reason))) + +(defun denote-lint--link-display-target (link) + "Return a short string describing the target of LINK." + (let ((target (plist-get link :target))) + (or target (plist-get link :link-text)))) + +;;; Report buffer + +(defun denote-lint--source-file-title (file) + "Return a title string for FILE. +If the file has a Denote title, use it; otherwise use the file name." + (condition-case nil + (let ((file-type (denote-filetype-heuristics file))) + (or (denote-retrieve-front-matter-title-value file file-type) + (file-name-nondirectory file))) + (error (file-name-nondirectory file)))) + +(defun denote-lint--insert-report-table (links) + "Insert an Org table describing broken LINKS." + (insert "\n|-\n| Source | Line | Target | Reason |\n|-\n") + (dolist (link links) + (let* ((source-file (plist-get link :source-file)) + (source-id (denote-retrieve-filename-identifier source-file)) + (line (plist-get link :line)) + (target (denote-lint--link-display-target link)) + (link-text (or (plist-get link :link-text) target)) + (encoded-link-text (url-hexify-string link-text)) + (reason (pcase (denote-lint--reason-category link) + ('missing-file "missing file") + ('missing-heading "missing heading") + ('unsupported-search "unsupported search") + (_ "unknown"))) + (detail (denote-lint--reason-detail link)) + (title (denote-lint--source-file-title source-file))) + (insert "|[[elisp:(denote-lint--jump-to-link \"" + source-id "\" " (number-to-string line) " \"" + encoded-link-text + "\")][" title "]]" + "|" (number-to-string line) + "|" target + "|" reason + (if detail + (concat " (" detail ")") + "") + "|\n"))) + (insert "|-\n") + (org-table-align)) + +(defun denote-lint--display-report (broken-links) + "Display BROKEN-LINKS in `denote-lint-report-buffer-name'." + (let ((buffer (get-buffer-create denote-lint-report-buffer-name))) + (with-current-buffer buffer + (denote-lint--insert-report broken-links) + (goto-char (point-min))) + (display-buffer buffer) + (message "Denote lint complete: %s issue%s found" + (length broken-links) + (if (= 1 (length broken-links)) "" "s")))) + +(defun denote-lint--collect-broken-links-async (files callback) + "Scan FILES asynchronously and call CALLBACK with broken links. +Any previously running asynchronous lint is cancelled first." + (denote-lint--cancel-async) + (message "Scanning Denote files asynchronously...") + (let ((captured-heading-regexp (denote-lint--capture-org-heading-regexp))) + (setq denote-lint--async-process + (async-start + `(lambda () + (condition-case err + (progn + (setq load-path ',load-path) + ,(async-inject-variables "\\`denote-.*\\'") + ,(async-inject-variables "\\`denote-lint-.*\\'") + (require 'denote-lint) + (setq denote-lint--async-org-heading-regexp ',captured-heading-regexp) + (add-hook 'org-mode-hook #'denote-lint--async-setup-org-heading-regexp) + (denote-lint--collect-broken-links ',files)) + (error (list 'denote-lint-async-error (error-message-string err))))) + (lambda (result) + (setq denote-lint--async-process nil) + (if (and (listp result) (eq 'denote-lint-async-error (car result))) + (message "Denote lint async error: %s" (cadr result)) + (funcall callback result))))))) + +(defun denote-lint--insert-report (broken-links) + "Insert an Org report describing BROKEN-LINKS." + (erase-buffer) + (org-mode) + (insert "#+title: Denote Link Lint Report\n" + "#+date: ") + (org-insert-time-stamp (current-time) t t) + (insert "\n\n") + (if (null broken-links) + (insert "No broken Denote links found.\n") + (insert "Found " (number-to-string (length broken-links)) + " broken or unverifiable Denote link" + (if (= 1 (length broken-links)) "" "s") + ".\n\n" + "Click the Source link to jump to the dead link.\n\n") + (let ((missing-file (seq-filter + (lambda (l) (eq 'missing-file (denote-lint--reason-category l))) + broken-links)) + (missing-heading (seq-filter + (lambda (l) (eq 'missing-heading (denote-lint--reason-category l))) + broken-links)) + (unsupported (seq-filter + (lambda (l) (eq 'unsupported-search (denote-lint--reason-category l))) + broken-links))) + (when missing-file + (insert "* Missing files\n") + (denote-lint--insert-report-table missing-file)) + (when missing-heading + (insert "* Missing headings\n") + (denote-lint--insert-report-table missing-heading)) + (when unsupported + (insert "* Unsupported heading searches\n") + (insert "These links use a search option that this checker cannot verify.\n") + (denote-lint--insert-report-table unsupported))))) + +(defun denote-lint--jump-to-link (source-id line link-text) + "Open the file identified by SOURCE-ID and jump to the link. +LINE is the expected line number. LINK-TEXT is the encoded raw +link text as it appears in the source file, which is decoded before +searching." + (interactive) + (setq link-text (url-unhex-string link-text)) + (let* ((file (denote-get-path-by-id source-id)) + (buffer (find-file-noselect file))) + (pop-to-buffer buffer) + (widen) + (goto-char (point-min)) + (forward-line (1- line)) + (when org-link-descriptive + (org-toggle-link-display)) + (let ((case-fold-search nil)) + (if (or (search-forward link-text nil t) + (progn + (goto-char (point-min)) + (forward-line (1- line)) + (search-forward (regexp-quote link-text) nil t))) + (message "Link found") + (message "Could not locate link to %s" link-text))))) + +;;; Grep report buffer + +(defun denote-lint--format-grep-line (link) + "Return a grep-style line for a broken LINK." + (let* ((source-file (plist-get link :source-file)) + (line (plist-get link :line)) + (target (denote-lint--link-display-target link)) + (reason (pcase (denote-lint--reason-category link) + ('missing-file "missing file") + ('missing-heading "missing heading") + ('unsupported-search "unsupported search") + (_ "unknown")))) + (format "%s:%s: %s - %s" + (expand-file-name source-file) + line + reason + target))) + +(defun denote-lint--display-grep-report (broken-links) + "Display BROKEN-LINKS in `denote-lint-grep-buffer-name'." + (let ((buffer (get-buffer-create denote-lint-grep-buffer-name))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (if (null broken-links) + (insert "No broken Denote links found.\n") + (dolist (link broken-links) + (insert (denote-lint--format-grep-line link) "\n")))) + (grep-mode) + (goto-char (point-min))) + (display-buffer buffer) + (message "Denote lint grep complete: %s issue%s found" + (length broken-links) + (if (= 1 (length broken-links)) "" "s")))) + +(defun denote-lint--run (files display-fn progress-message) + "Check FILES for broken Denote links and pass them to DISPLAY-FN. +When `denote-lint-async-by-default' is non-nil, run asynchronously +and ignore PROGRESS-MESSAGE; otherwise print PROGRESS-MESSAGE and +run synchronously." + (if denote-lint-async-by-default + (denote-lint--collect-broken-links-async files display-fn) + (message progress-message) + (funcall display-fn (denote-lint--collect-broken-links files)))) + +;;;###autoload +(defun denote-lint-current-buffer () + "Check the current Denote buffer for broken links and show a report. +When `denote-lint-async-by-default' is non-nil, run asynchronously." + (interactive) + (unless (and buffer-file-name + (denote-file-has-denoted-filename-p buffer-file-name)) + (user-error "Current file is not a Denote note")) + (denote-lint--run (list buffer-file-name) + #'denote-lint--display-report + "Scanning current buffer for broken Denote links...")) + +;;;###autoload +(defun denote-lint () + "Check all Org Denote files for broken links and show a report. +When `denote-lint-async-by-default' is non-nil, run asynchronously." + (interactive) + (denote-lint--run (denote-directory-files denote-lint-file-regexp nil t) + #'denote-lint--display-report + "Scanning Denote files for broken links...")) + +;;;###autoload +(defun denote-lint-grep-current-buffer () + "Check the current Denote buffer for broken links and show results in a grep buffer. +When `denote-lint-async-by-default' is non-nil, run asynchronously." + (interactive) + (unless (and buffer-file-name + (denote-file-has-denoted-filename-p buffer-file-name)) + (user-error "Current file is not a Denote note")) + (denote-lint--run (list buffer-file-name) + #'denote-lint--display-grep-report + "Scanning current buffer for broken Denote links...")) + +;;;###autoload +(defun denote-lint-grep () + "Check all Org Denote files for broken links and show results in a grep buffer. +When `denote-lint-async-by-default' is non-nil, run asynchronously." + (interactive) + (denote-lint--run (denote-directory-files denote-lint-file-regexp nil t) + #'denote-lint--display-grep-report + "Scanning Denote files for broken links...")) + +(provide 'denote-lint) +;;; denote-lint.el ends here