commit effd72b4e436c28c7246f291dea144959ca31b38
Author: andsy10 <andsy1016@gmail.com>
Date: Wed, 22 Jul 2026 07:23:19 +0800
Inistal commit
Diffstat:
| A | denote-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