;;; tp-search.el --- Pattern matching and property search for tp -*- lexical-binding: t -*- ;; Copyright (C) 2024-2026 Geekinney ;; Author: Geekinney (kinneyzhang666@gmail.com) ;; This program is free software; you can redistribute it and/or ;; modify it under the terms of the GNU General Public License as ;; published by the Free Software Foundation; either version 3 of ;; the License, or (at your option) any later version. ;;; Commentary: ;; Pattern-driven property application (`tp-match-*', `tp-regexp-*') ;; and property-run search/navigation (`tp-search', `tp-search-map', ;; `tp-forward', `tp-backward', `tp-forward-do', `tp-backward-do'). ;;; Code: (require 'cl-lib) (require 'text-property-search) (require 'tp-core) (require 'tp-layer) (require 'tp-ops) (defconst tp-any-value (make-symbol "tp-any-value") "Unique wildcard used by tp property-search APIs. Omitting VALUE selects this sentinel automatically. Pass the variable `tp-any-value' explicitly when a later positional argument is needed and any present direct value should match. An explicit nil VALUE is therefore available for exact, presence-aware nil matching.") (defun tp--pattern-apply-single (pattern properties apply-fn object literal &optional start end subexp) "Apply PROPERTIES with APPLY-FN to matches of single PATTERN in OBJECT. When LITERAL is non-nil, PATTERN is matched literally; otherwise it is a regexp. APPLY-FN is called with (START END PROPS OBJECT) for each match. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped (matching the buffer path's historical `narrow-to-region' behavior, now uniform across object types). Matching behaves as if OBJECT consisted only of that portion (the buffer path narrows, the string path matches against the substring), so no match crosses the boundaries. When SUBEXP is non-nil, it names a capture group of PATTERN: the properties and returned regions cover (match-beginning SUBEXP) to \(match-end SUBEXP) of each match, and a match in which that group does not participate contributes nothing. The scan still advances past the whole match. A SUBEXP larger than PATTERN's group count \(per `regexp-opt-depth') signals an error instead of silently matching nothing. For strings, returns a NEW string with properties applied \(non-destructive). For buffers, modifies in-place and returns list of regions. Zero-width matches (an empty literal pattern, or a regexp that can match the empty string) are recorded and the scan advances one position past them, so the search always terminates." (let ((regexp (if literal (regexp-quote pattern) pattern))) ;; Reversed bounds are swapped, not signaled: the buffer path's ;; narrow-to-region always did this, so the string path follows. (when (and start end (> start end)) (cl-rotatef start end)) ;; A group number beyond the pattern's group count could never ;; match; make the typo loud instead of a silent no-op. (when (and subexp (> subexp (regexp-opt-depth regexp))) (error "Regexp %S has no group %d" pattern subexp)) (cond ;; String object ((stringp object) ;; First, collect all match positions from the original string. ;; Bounded searches run against the substring so matches cannot ;; cross the [START, END) boundaries; positions are shifted back ;; into whole-string coordinates afterwards. (let* ((from (max (or start 0) 0)) (to (min (or end (length object)) (length object))) (searchable (if (and (= from 0) (= to (length object))) object (substring object from to))) (matches nil) (pos 0) (limit (- to from))) (while (and (<= pos limit) (string-match regexp searchable pos)) (let ((beg (match-beginning 0)) (end (match-end 0)) (sub-beg (match-beginning (or subexp 0))) (sub-end (match-end (or subexp 0)))) ;; A group that does not participate contributes nothing. (when sub-beg (push (cons (+ from sub-beg) (+ from sub-end)) matches)) (setq pos (if (= beg end) (1+ beg) end)))) ;; Apply function to each match in order (reverse to get correct order) ;; Make a copy to ensure original string is not modified (let ((result (copy-sequence object))) (dolist (match (nreverse matches)) (when properties (setq result (funcall apply-fn (car match) (cdr match) properties result)))) result))) ;; Buffer or nil (current buffer) (t (let ((buf (or object (current-buffer)))) (tp-with-current-buffer buf (save-excursion (save-restriction (when (or start end) (narrow-to-region (max (or start (point-min)) (point-min)) (min (or end (point-max)) (point-max)))) (goto-char (point-min)) (let (regions (keep-going t)) (while (and keep-going (re-search-forward regexp nil t)) (let ((beg (match-beginning 0)) (end (match-end 0)) (sub-beg (match-beginning (or subexp 0))) (sub-end (match-end (or subexp 0)))) ;; A group that does not participate contributes nothing. (when sub-beg (when properties (funcall apply-fn sub-beg sub-end properties buf)) (push (cons sub-beg sub-end) regions)) ;; Guard against zero-width matches looping forever (when (= beg end) (if (eobp) (setq keep-going nil) (forward-char 1))))) (nreverse regions)))))))))) (defun tp--pattern-apply (pattern properties apply-fn object literal &optional start end subexp) "Apply PROPERTIES with APPLY-FN to matches of PATTERN. When LITERAL is non-nil, patterns are matched literally; otherwise they are regexps. APPLY-FN is called with (START END PROPS OBJECT) for each match. START and END restrict matching to [START, END) in native coordinates; SUBEXP names a capture group to target (see `tp--pattern-apply-single'). For strings, returns a NEW string with properties applied \(non-destructive). For buffers, returns list of regions." (let ((patterns (if (listp pattern) pattern (list pattern)))) (cond ;; String object ((stringp object) (let ((result object)) (dolist (p patterns) (setq result (tp--pattern-apply-single p properties apply-fn result literal start end subexp))) result)) ;; Buffer or nil (current buffer) (t (let ((all-regions nil)) (dolist (p patterns) (let ((regions (tp--pattern-apply-single p properties apply-fn object literal start end subexp))) (setq all-regions (append all-regions regions)))) all-regions))))) (defun tp--match-apply-single (pattern properties apply-fn object &optional start end) "Apply PROPERTIES with APPLY-FN to literal matches of PATTERN in OBJECT. START and END restrict matching to [START, END) in native coordinates. For strings, returns a new string with properties applied (non-destructive). For buffers, modifies in-place and returns list of regions." (tp--pattern-apply-single pattern properties apply-fn object t start end)) (defun tp--match-apply (pattern properties apply-fn &optional object start end) "Apply PROPERTIES with APPLY-FN to matches of PATTERN. PATTERN can be a string or a list of strings (multiple patterns). When PATTERN is a list, each element is a pattern to match. APPLY-FN is called with (START END PROPS OBJECT) for each match. START and END restrict matching to [START, END) in native coordinates. For strings, returns a NEW string with properties applied (non-destructive). For buffers, returns list of regions." (tp--pattern-apply pattern properties apply-fn object t start end)) (defun tp--regexp-apply-single (pattern properties apply-fn object &optional start end subexp) "Apply PROPERTIES with APPLY-FN to regexp matches of PATTERN in OBJECT. APPLY-FN is called with (START END PROPS OBJECT) for each match. START and END restrict matching to [START, END) in native coordinates; SUBEXP names a capture group to target. For strings, returns a NEW string with properties applied (non-destructive). For buffers, modifies in-place and returns list of regions." (tp--pattern-apply-single pattern properties apply-fn object nil start end subexp)) (defun tp--regexp-apply (pattern properties apply-fn &optional object start end subexp) "Apply PROPERTIES with APPLY-FN to regexp matches of PATTERN. PATTERN can be a string (single regexp) or a list of strings (multiple regexps). When PATTERN is a list, each element is a regexp to match. APPLY-FN is called with (START END PROPS OBJECT) for each match. START and END restrict matching to [START, END) in native coordinates; SUBEXP names a capture group to target. For strings, returns a NEW string with properties applied (non-destructive). For buffers, returns list of regions." (tp--pattern-apply pattern properties apply-fn object nil start end subexp)) (defun tp-match-set (pattern plist &optional object start end) "Set properties on all occurrences of PATTERN. (tp-match-set PATTERN PLIST &optional OBJECT START END) PATTERN is a string (single pattern) or list of strings (multiple patterns). Each pattern will be matched and have properties applied. PLIST is a property list like \\='(face bold help-echo \"tip\"), or a symbol representing a layer/group name defined by `define-tp' or `define-tp-group'. OBJECT is a buffer or string; nil means current buffer. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped. Matching behaves as if OBJECT consisted only of that portion, so no match crosses the boundaries. Returns: - For strings: a NEW string with properties applied (the original string is not modified) - For buffers: list of (START . END) pairs for all matches." (tp--match-apply pattern (tp--prepare-direct-properties plist) #'tp-set object start end)) (defun tp-match-reset (pattern plist &optional object start end) "Reset (completely replace) properties on all occurrences of PATTERN. (tp-match-reset PATTERN PLIST &optional OBJECT START END) PATTERN is a string (single pattern) or list of strings (multiple patterns). PLIST is a property list like \\='(face bold help-echo \"tip\"), or a symbol representing a layer/group name defined by `define-tp' or `define-tp-group'. OBJECT is a buffer or string; nil means current buffer. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped. Unlike `tp-match-set', this completely replaces all existing properties. For strings, returns a NEW string (original is not modified). For buffers, modifies in-place and returns list of (START . END) regions." (tp--match-apply pattern (tp--prepare-direct-properties plist) #'tp--reset-apply object start end)) (defun tp-match-add (pattern plist &optional object start end) "Add/update properties on all occurrences of PATTERN. (tp-match-add PATTERN PLIST &optional OBJECT START END) PATTERN is a string (single pattern) or list of strings (multiple patterns). PLIST is a property list like \\='(face bold help-echo \"tip\"), or a symbol representing a layer/group name defined by `define-tp' or `define-tp-group'. OBJECT is a buffer or string; nil means current buffer. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped. Unlike `tp-match-set', this deeply merges nested properties. For strings, returns a NEW string (original is not modified). For buffers, modifies in-place and returns list of (START . END) regions." (tp--match-apply pattern (tp--prepare-direct-properties plist) #'tp--deep-merge-apply object start end)) (defun tp-regexp-set (pattern plist &optional object start end subexp) "Set properties on all matches of PATTERN (regexp). (tp-regexp-set PATTERN PLIST &optional OBJECT START END SUBEXP) PATTERN is a string (single regexp) or list of strings (multiple regexps). Each pattern will be matched and have properties applied. PLIST is a property list like \\='(face bold help-echo \"tip\"), or a symbol representing a layer/group name defined by `define-tp' or `define-tp-group'. OBJECT is a buffer or string; nil means current buffer. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped. Matching behaves as if OBJECT consisted only of that portion, so no match crosses the boundaries. When SUBEXP is non-nil, it names a capture group of PATTERN (1 for the first group, like font-lock highlights): properties apply to that group of each match instead of the whole match, and a match in which the group does not participate contributes nothing. A SUBEXP larger than PATTERN's group count signals an error. Returns: - For strings: a NEW string with properties applied (the original string is not modified) - For buffers: list of (START . END) pairs for all matches." (tp--regexp-apply pattern (tp--prepare-direct-properties plist) #'tp-set object start end subexp)) (defun tp-regexp-reset (pattern plist &optional object start end subexp) "Reset (completely replace) properties on all regexp matches of PATTERN. (tp-regexp-reset PATTERN PLIST &optional OBJECT START END SUBEXP) PATTERN is a string (single regexp) or list of strings (multiple regexps). PLIST is a property list like \\='(face bold help-echo \"tip\"), or a symbol representing a layer/group name defined by `define-tp' or `define-tp-group'. OBJECT is a buffer or string; nil means current buffer. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped. When SUBEXP is non-nil, properties apply to that capture group of each match instead of the whole match; a match in which the group does not participate contributes nothing. A SUBEXP larger than PATTERN's group count signals an error. Unlike `tp-regexp-set', this completely replaces all existing properties. For strings, returns a NEW string (original is not modified). For buffers, modifies in-place and returns list of (START . END) regions." (tp--regexp-apply pattern (tp--prepare-direct-properties plist) #'tp--reset-apply object start end subexp)) (defun tp-regexp-add (pattern plist &optional object start end subexp) "Add/update properties on all regexp matches of PATTERN. (tp-regexp-add PATTERN PLIST &optional OBJECT START END SUBEXP) PATTERN is a string (single regexp) or list of strings (multiple regexps). PLIST is a property list like \\='(face bold help-echo \"tip\"), or a symbol representing a layer/group name defined by `define-tp' or `define-tp-group'. OBJECT is a buffer or string; nil means current buffer. START and END restrict matching to the [START, END) portion of OBJECT, in native coordinates (0-based for strings, 1-based for buffers); nil means the object's bounds. If START > END the bounds are swapped. When SUBEXP is non-nil, properties apply to that capture group of each match instead of the whole match; a match in which the group does not participate contributes nothing. A SUBEXP larger than PATTERN's group count signals an error. Unlike `tp-regexp-set', this deeply merges nested properties. For strings, returns a NEW string (original is not modified). For buffers, modifies in-place and returns list of (START . END) regions." (tp--regexp-apply pattern (tp--prepare-direct-properties plist) #'tp--deep-merge-apply object start end subexp)) (defun tp-search-forward (property &optional value predicate not-current) "Search forward from point for text whose PROPERTY matches VALUE. This is a raw wrapper: PROPERTY, VALUE, PREDICATE and NOT-CURRENT are passed unchanged to `text-property-search-forward', whose semantics apply in full - including the primitive's nil-PREDICATE default of matching values that are non-nil and NOT `equal' to VALUE. On success point moves to the end of the matched region and a prop-match object is returned; otherwise nil. Obsolete since tp 0.3.0: call `tp-forward' for tp's `equal'-matching search (which also supports string OBJECTs and repeat counts), or call the Emacs primitive `text-property-search-forward' directly for raw use - this wrapper adds nothing to it." (text-property-search-forward property value predicate not-current)) (make-obsolete 'tp-search-forward 'tp-forward "0.3.0") (defun tp-search-backward (property &optional value predicate not-current) "Search backward from point for text whose PROPERTY matches VALUE. This is a raw wrapper: PROPERTY, VALUE, PREDICATE and NOT-CURRENT are passed unchanged to `text-property-search-backward', whose semantics apply in full - including the primitive's nil-PREDICATE default of matching values that are non-nil and NOT `equal' to VALUE. On success point moves to the beginning of the matched region and a prop-match object is returned; otherwise nil. Obsolete since tp 0.3.0: call `tp-backward' for tp's `equal'-matching search (which also supports string OBJECTs and repeat counts), or call the Emacs primitive `text-property-search-backward' directly for raw use - this wrapper adds nothing to it." (text-property-search-backward property value predicate not-current)) (make-obsolete 'tp-search-backward 'tp-backward "0.3.0") (defun tp--property-match-p (value prop-value predicate) "Return non-nil when PROP-VALUE matches VALUE under PREDICATE. PREDICATE follows the convention tp uses for `text-property-search-forward': nil and t both mean the values must be `equal' (tp's 0.2.0 symmetric matching contract); a function is called with VALUE and PROP-VALUE and matches when it returns non-nil. `tp-any-value' matches every PROP-VALUE." (if (functionp predicate) (funcall predicate value prop-value) (or (eq value tp-any-value) (equal value prop-value)))) (defun tp--property-matches (object start end property value predicate) "Collect direct PROPERTY matches in OBJECT between START and END. Each result is a canonical `tp--match'. A run is eligible only when PROPERTY is present in `text-properties-at', so an explicit nil value is distinct from absence. Boundaries caused only by unrelated properties are joined back into one PROPERTY run." (let ((obj (or object (current-buffer))) (pos start) (results nil)) (while (< pos end) (let* ((next (or (next-property-change pos object end) end)) (presence (tp--presence-at pos property object)) (prop-value (tp--presence-value presence))) (when (and (tp--presence-present-p presence) (tp--property-match-p value prop-value predicate)) (let ((previous (car results))) (if (and previous (= (tp--native-range-end (tp--match-range previous)) pos) (equal (tp--match-value previous) prop-value)) (setf (tp--native-range-end (tp--match-range previous)) next) (push (tp--make-match (tp--make-native-range obj (tp--object-kind obj) pos next) property prop-value) results)))) (setq pos next))) (nreverse results))) (defun tp--property-search-backward (property value &optional predicate not-current) "Search backward for the previous region where PROPERTY matches VALUE. Only direct PROPERTY runs are candidates. VALUE and PREDICATE follow `tp--property-match-p'. NOT-CURRENT skips the run containing point." (unless (bobp) (let* ((origin (point)) (matches (nreverse (tp--property-matches (current-buffer) (point-min) (point-max) property value predicate))) (found (seq-find (lambda (match) (and (< (tp--native-range-start (tp--match-range match)) origin) (not (and not-current (<= (tp--native-range-start (tp--match-range match)) origin) (>= (tp--native-range-end (tp--match-range match)) origin))))) matches))) (when found (let ((range (tp--match-range found))) (goto-char (tp--native-range-start range)) (setf (tp--native-range-end range) (min origin (tp--native-range-end range))) (tp--match-to-prop-match found)))))) (defun tp--property-search-forward (property value predicate not-current) "Search forward for PROPERTY matching VALUE under PREDICATE. When NOT-CURRENT is non-nil, skip the run containing point." (let* ((origin (point)) (found (seq-find (lambda (match) (and (> (tp--native-range-end (tp--match-range match)) origin) (not (and not-current (<= (tp--native-range-start (tp--match-range match)) origin) (< origin (tp--native-range-end (tp--match-range match))))))) (tp--property-matches (current-buffer) (point-min) (point-max) property value predicate)))) (when found (let ((range (tp--match-range found))) (goto-char (tp--native-range-end range)) (setf (tp--native-range-start range) (max origin (tp--native-range-start range))) (tp--match-to-prop-match found))))) (defun tp--search-result-value (object start end property value) "Return matches for PROPERTY and VALUE in OBJECT between START and END." (let* ((range (tp--native-range-from-object object start end)) (request (tp--make-request :operation :search :range range :property property :value value :public-return :matches)) (matches (tp--property-matches (tp--native-range-object range) (tp--native-range-start range) (tp--native-range-end range) property value nil))) (tp--result-public-value (tp--make-result :request request :matches matches)))) (cl-defun tp-forward (property &optional (value tp-any-value) object n predicate not-current) "Search forward N times for text with PROPERTY. Omitted VALUE matches any present direct value; explicit nil matches a present nil value. Pass `tp-any-value' when later positional arguments are needed with wildcard matching. N is the number of searches, defaulting to 1. OBJECT can be a buffer or string; nil defaults to current buffer. PREDICATE customizes matching: nil (the default) and t both keep the `equal' contract; a function is called with VALUE and the run value. NOT-CURRENT skips a matching buffer run containing point. For buffers, each search starts from point and each successful one moves point to the end of its matched region; the return value is the prop-match object of the N-th search, or nil when that search found nothing. For strings, point is not involved at all: the return value is the list of the FIRST N matching regions counted from position 0 of the string, each a (START END VALUE) list with 0-based positions - not the N-th match alone. Fewer than N matches return however many exist." (let ((count (or n 1))) (cond ((stringp object) (let ((matches (tp--property-matches object 0 (length object) property value predicate))) (mapcar #'tp--match-to-list (seq-take matches count)))) (t (let ((result nil) (buf (or object (current-buffer)))) (tp-with-current-buffer buf (catch 'done (dotimes (_ count) (setq result (tp--property-search-forward property value predicate not-current)) (unless result (throw 'done nil))))) result))))) (cl-defun tp-backward (property &optional (value tp-any-value) object n predicate not-current) "Search backward N times for text with PROPERTY. N is the number of searches, defaulting to 1. Omitted VALUE matches any present direct value; explicit nil matches a present nil value. Pass `tp-any-value' when later positional arguments are needed with wildcard matching. OBJECT can be a buffer or string; nil defaults to current buffer. PREDICATE customizes matching: nil (the default) and t both keep the 0.2.0 contract where a region matches when its PROPERTY value is `equal' to VALUE; a function is called with VALUE and the region's PROPERTY value and matches when it returns non-nil. NOT-CURRENT, when non-nil, skips a matching region containing point \(or ending exactly at point), mirroring `text-property-search-backward'. It only applies to the buffer path; strings have no point, so it is ignored there. For buffers, returns the prop-match object from the last successful search. For strings, returns a list of (START END VALUE) for the last N matches in reverse order (from end to start). Uses the same presence-aware run scanner for strings and buffers." (let ((count (or n 1))) (cond ((stringp object) (let ((matches (nreverse (tp--property-matches object 0 (length object) property value predicate)))) (mapcar #'tp--match-to-list (seq-take matches count)))) (t (let ((result nil) (buf (or object (current-buffer)))) (tp-with-current-buffer buf (catch 'done (dotimes (_ count) (setq result (tp--property-search-backward property value predicate not-current)) (unless result (throw 'done nil))))) result))))) (cl-defun tp--forward-do (function property &optional (value tp-any-value) object times start end predicate not-current) "Internal: search forward TIMES for PROPERTY, call FUNCTION on last match. FUNCTION receives two arguments: the prop-match object (or list for strings) and OBJECT. TIMES is the number of searches, defaulting to 1. Omitted VALUE matches any present value; explicit nil matches a present nil value. OBJECT can be a buffer or string; nil defaults to current buffer. START and END define the search range; defaults are object start and end. PREDICATE and NOT-CURRENT are passed to each underlying search (see `tp-forward'); nil PREDICATE keeps the 0.2.0 `equal' matching. FUNCTION is called only when the TIMES-th match exists; if fewer matches are available, nothing is applied. Returns the number of matches found (at most TIMES)." (let ((count (or times 1))) (cond ;; String object ((stringp object) (let* ((start-pos (or start 0)) (end-pos (or end (length object))) (all-matches (tp--property-matches object start-pos end-pos property value predicate)) (matches (seq-take all-matches count))) ;; All-or-nothing, mirroring the buffer path: FUNCTION targets ;; the TIMES-th match specifically, so when fewer matches exist ;; acting on a different one would hit the wrong target. (when (= (length matches) count) (funcall function (car (last matches)) object)) (length matches))) ;; Buffer or nil (t (let* ((buf (or object (current-buffer))) (matches 0)) (tp-with-current-buffer buf (let ((search-start (or start (point-min))) (search-end (or end (point-max)))) (save-excursion (goto-char search-start) (dotimes (i count) (when-let* ((match (tp--property-search-forward property value predicate not-current))) (when (<= (prop-match-end match) search-end) (when (= i (1- count)) (funcall function match buf)) (cl-incf matches))))))) matches))))) (defun tp--replace-match-text (function arity match obj &optional idx) "Replace the text of MATCH in OBJ with the result of calling FUNCTION. MATCH is either a (START END VALUE) list (string matches) or a prop-match struct (buffer matches). ARITY is the precomputed \(func-arity FUNCTION); depending on it, FUNCTION is called with \(TEXT), (TEXT START), (TEXT START END) or - when IDX is non-nil and FUNCTION accepts a 4th argument - (TEXT START END IDX). If FUNCTION returns a string, it replaces the matched text: - For string OBJ the replacement happens in place and must have the same length as the match. A different length signals an error. The replacement's text properties (including their absence) are copied onto the replaced portion. - For buffer OBJ the match is replaced via `delete-region' + `insert' \(the buffer may grow or shrink). Any non-string return value leaves OBJ untouched." (let* ((range (when (tp--match-p match) (tp--match-range match))) (m-start (cond (range (tp--native-range-start range)) ((listp match) (car match)) (t (prop-match-beginning match)))) (m-end (cond (range (tp--native-range-end range)) ((listp match) (cadr match)) (t (prop-match-end match)))) (text (if (stringp obj) (substring obj m-start m-end) (buffer-substring m-start m-end))) (max-arity (cdr arity)) (can-accept-start (or (eq max-arity 'many) (and (numberp max-arity) (>= max-arity 2)))) (can-accept-end (or (eq max-arity 'many) (and (numberp max-arity) (>= max-arity 3)))) (can-accept-idx (and idx (or (eq max-arity 'many) (and (numberp max-arity) (>= max-arity 4))))) (new-text (cond (can-accept-idx (funcall function text m-start m-end idx)) (can-accept-end (funcall function text m-start m-end)) (can-accept-start (funcall function text m-start)) (t (funcall function text))))) (when (stringp new-text) (if (stringp obj) ;; For strings: copy text content and properties separately. ;; A string cannot change length in place, so a replacement ;; of a different length would silently corrupt the text ;; (truncation or residue); reject it clearly instead. (let ((len (- m-end m-start))) (unless (= (length new-text) len) (error "TP: replacement %S is %d chars but the match is %d; \ strings cannot change length in place -- use a buffer OBJECT for \ length-changing replacements" new-text (length new-text) len)) (store-substring obj m-start new-text) ;; Copy properties from new-text to obj. Ranges with nil ;; properties are copied too, so FUNCTION can REMOVE ;; properties by returning a stripped string. (let ((pos 0)) (while (< pos len) (let* ((props (text-properties-at pos new-text)) (next-change (or (next-property-change pos new-text) len))) (set-text-properties (+ m-start pos) (+ m-start (min next-change len)) props obj) (setq pos next-change))))) ;; For buffers, delete and insert (unless (equal new-text text) (save-excursion (delete-region m-start m-end) (goto-char m-start) (insert new-text))))))) (cl-defun tp-forward-do (function property &optional (value tp-any-value) object times start end predicate not-current) "Search forward TIMES times for PROPERTY; apply FUNCTION at the Nth match. Despite the -do suffix this is NOT a for-each: the search advances through TIMES matches and FUNCTION is applied only to the final \(TIMES-th) one. Use `tp-search-map' to apply a function to EVERY match. FUNCTION receives (TEXT &optional START END) where TEXT is the matched text, START and END are the positions of the match. The return value of FUNCTION replaces the matched text in the string or buffer. PROPERTY is the text property to search for. Omitted VALUE matches any present value; explicit nil matches a present nil value. Pass `tp-any-value' when later positional arguments are needed with wildcard matching. OBJECT can be a buffer or string; nil defaults to current buffer. TIMES is the number of searches, defaulting to 1. The function searches TIMES times but only applies FUNCTION to the last (Nth) match found. START and END define the search range; defaults are object start and end. PREDICATE customizes matching: nil (the default) and t both keep the 0.2.0 contract where a region matches when its PROPERTY value is `equal' to VALUE; a function is called with VALUE and the region's PROPERTY value and matches when it returns non-nil. NOT-CURRENT is passed to each underlying `text-property-search-forward' call; it only applies to the buffer path (strings have no point). Returns the number of successful matches. Note: For string objects, the replacement text must have the same length as the original matched text, since strings have fixed length in Emacs; a different length signals an error. Buffer replacements may grow or shrink. Example: ;; Upcase only the last (2nd) match (setq my-string (copy-sequence \"hello world hello\")) (tp-set 0 5 \\='(marker t) my-string) (tp-set 12 17 \\='(marker t) my-string) (tp-forward-do #\\='upcase \\='marker tp-any-value my-string 2) ;; => \"hello world HELLO\" - only the 2nd match is upcased ;; Use start and end positions in function (tp-forward-do (lambda (txt start end) (format \"[%d-%d]%s\" start end txt)) \\='marker tp-any-value my-string 2) ;; Search within a range (tp-forward-do #\\='upcase \\='marker tp-any-value my-string 1 0 10)" (let ((arity (func-arity function))) (tp--forward-do (lambda (match obj) (tp--replace-match-text function arity match obj)) property value object times start end predicate not-current))) (cl-defun tp--backward-do (function property &optional (value tp-any-value) object times start end predicate not-current) "Internal: search backward TIMES for PROPERTY, call FUNCTION on last match. FUNCTION receives two arguments: the prop-match object (or list for strings) and OBJECT. TIMES is the number of searches, defaulting to 1. Omitted VALUE matches any present value; explicit nil matches a present nil value. OBJECT can be a buffer or string; nil defaults to current buffer. START and END define the search range; defaults are object start and end. PREDICATE and NOT-CURRENT are passed to each underlying search (see `tp-backward'); nil PREDICATE keeps the 0.2.0 `equal' matching. FUNCTION is called only when the TIMES-th match exists; if fewer matches are available, nothing is applied. Returns the number of matches found (at most TIMES)." (let ((count (or times 1))) (cond ;; String object - reverse the matches ((stringp object) (let* ((start-pos (or start 0)) (end-pos (or end (length object))) (all-matches (tp--property-matches object start-pos end-pos property value predicate)) (matches (seq-take (nreverse all-matches) count))) ;; All-or-nothing; see tp--forward-do. (when (= (length matches) count) (funcall function (car (last matches)) object)) (length matches))) ;; Buffer or nil (t (let* ((buf (or object (current-buffer))) (matches 0)) (tp-with-current-buffer buf (let ((search-start (or start (point-min))) (search-end (or end (point-max)))) (save-excursion (goto-char search-end) (dotimes (i count) ;; `equal' matching by default, same as tp--forward-do's ;; predicate t. (when-let* ((match (tp--property-search-backward property value predicate not-current))) (when (>= (prop-match-beginning match) search-start) (when (= i (1- count)) (funcall function match buf)) (cl-incf matches))))))) matches))))) (cl-defun tp-backward-do (function property &optional (value tp-any-value) object times start end predicate not-current) "Search backward TIMES times for PROPERTY; apply FUNCTION at the Nth match. Despite the -do suffix this is NOT a for-each: the search walks back through TIMES matches and FUNCTION is applied only to the final \(TIMES-th) one. Use `tp-search-map' to apply a function to EVERY match. FUNCTION receives (TEXT &optional START END) where TEXT is the matched text, START and END are the positions of the match. The return value of FUNCTION replaces the matched text in the string or buffer. PROPERTY is the text property to search for. Omitted VALUE matches any present value; explicit nil matches a present nil value. Pass `tp-any-value' when later positional arguments are needed with wildcard matching. OBJECT can be a buffer or string; nil defaults to current buffer. TIMES is the number of searches, defaulting to 1. The function searches TIMES times but only applies FUNCTION to the last (Nth) match found. START and END define the search range; defaults are object start and end. PREDICATE customizes matching: nil (the default) and t both keep the 0.2.0 contract where a region matches when its PROPERTY value is `equal' to VALUE; a function is called with VALUE and the region's PROPERTY value and matches when it returns non-nil. NOT-CURRENT, when non-nil, skips a matching region containing point on each underlying search; it only applies to the buffer path \(strings have no point). Returns the number of successful matches. Note: For string objects, the replacement text must have the same length as the original matched text, since strings have fixed length in Emacs; a different length signals an error. Buffer replacements may grow or shrink. Example: ;; Upcase only the last (2nd) match (setq my-string (copy-sequence \"hello world hello\")) (tp-set 0 5 \\='(marker t) my-string) (tp-set 12 17 \\='(marker t) my-string) (tp-backward-do #\\='upcase \\='marker tp-any-value my-string 2) ;; => \"HELLO world hello\" - only the 2nd (last) match is upcased ;; Use start and end positions in function (tp-backward-do (lambda (txt start end) (format \"[%d-%d]%s\" start end txt)) \\='marker tp-any-value my-string 2) ;; Search within a range (tp-backward-do #\\='upcase \\='marker tp-any-value my-string 1 0 10)" (let ((arity (func-arity function))) (tp--backward-do (lambda (match obj) (tp--replace-match-text function arity match obj)) property value object times start end predicate not-current))) (cl-defun tp-search (start-or-string &optional end-or-property (property-or-value tp-any-value property-value-supplied-p) (value tp-any-value value-supplied-p) object) "Search for all text with PROPERTY in a buffer/string range or entire string. This function supports two calling conventions: 1. Buffer/string region: (tp-search START END PROPERTY &optional VALUE OBJECT) 2. Entire string: (tp-search STRING PROPERTY &optional VALUE) When VALUE is omitted, match every run where PROPERTY is directly present. An explicit nil matches only directly present nil values. Use `tp-any-value' explicitly when OBJECT must also be supplied. START-OR-STRING selects the range start or complete string. END-OR-PROPERTY, PROPERTY-OR-VALUE, VALUE, and OBJECT complete the selected calling convention. Returns a list of (START END VALUE) lists for all matching regions. Each element contains the start position, end position, and property value." (cond ((stringp start-or-string) (tp--search-result-value start-or-string 0 (length start-or-string) end-or-property (if property-value-supplied-p property-or-value tp-any-value))) ((numberp start-or-string) (tp--search-result-value (or object (current-buffer)) start-or-string end-or-property property-or-value (if value-supplied-p value tp-any-value))) (t (error "Invalid first argument: %S" start-or-string)))) (cl-defun tp--search-do (function property &optional (value tp-any-value) object start end) "Internal: Execute FUNCTION on all matches of PROPERTY. Signature: (tp--search-do FUNCTION PROPERTY &optional VALUE OBJECT START END) FUNCTION receives two arguments: the match, as a (START END VALUE) list, and OBJECT. PROPERTY is the text property to search for. Omitted VALUE matches any present value; explicit nil matches a present nil value. OBJECT can be a buffer or string; nil defaults to current buffer. START and END define the search range; defaults are object start and end. Returns the number of matches processed. For buffers, FUNCTION is called with OBJECT as the current buffer, and the match positions handed to FUNCTION are tracked with markers, so FUNCTION may safely change the length of earlier matches (e.g. replace their text): later matches still receive their up-to-date positions." (let ((obj (or object (current-buffer)))) (if (stringp obj) (let* ((s (or start 0)) (e (or end (length obj))) (matches (tp--property-matches obj s e property value nil))) (dolist (match matches) (funcall function (tp--match-to-list match) obj)) (length matches)) ;; Buffer: do all the work with OBJ current, and track match ;; positions with markers so length-changing edits made by ;; FUNCTION on earlier matches don't invalidate later positions. (tp-with-current-buffer obj (let* ((s (or start (point-min))) (e (or end (point-max))) (matches (tp--property-matches obj s e property value nil)) (marked (mapcar (lambda (m) ;; Begin markers advance on insertion at ;; their position so adjacent runs stay ;; correct after a replacement. (let ((range (tp--match-range m))) (list (copy-marker (tp--native-range-start range) t) (copy-marker (tp--native-range-end range)) (tp--match-value m)))) matches))) (unwind-protect (dolist (m marked) (funcall function (list (marker-position (car m)) (marker-position (cadr m)) (caddr m)) obj)) (dolist (m marked) (set-marker (car m) nil) (set-marker (cadr m) nil))) (length marked)))))) (cl-defun tp-search-map (function property &optional (value tp-any-value) object start end) "Apply FUNCTION to all matches of PROPERTY in OBJECT. Signature: (tp-search-map FUNCTION PROPERTY &optional VALUE OBJECT START END) FUNCTION receives (TEXT &optional START END IDX) where: - TEXT is the matched text - START and END are the positions of the match - IDX is the 0-based index of the current match FUNCTION can either: - Return a new/modified string to replace the matched text - Modify the text properties of the argument and return it - Return nil to skip replacement PROPERTY is the text property to search for. Omitted VALUE matches any present value; explicit nil matches a present nil value. Pass `tp-any-value' when later positional arguments are needed with wildcard matching. OBJECT can be a buffer or string; nil defaults to current buffer. START and END define the search range; defaults are object start and end. Returns the number of matches processed. Note: For string objects, replacement text must have the same length as the original matched text, since strings have fixed length in Emacs; a different length signals an error. Buffer replacements may grow or shrink. Example: ;; Upcase all matched text (tp-search-map #\\='upcase \\='marker tp-any-value my-string) ;; Add properties to matched text (tp-search-map (lambda (txt) (tp-add txt \\='face \\='bold)) \\='marker tp-any-value str) ;; Use start, end, and index (tp-search-map (lambda (txt start end idx) (format \"[%d:%d-%d]%s\" idx start end txt)) \\='marker tp-any-value str) ;; Search within a range (tp-search-map #\\='upcase \\='marker tp-any-value my-string 0 10)" (let ((idx 0) (arity (func-arity function))) (tp--search-do (lambda (match obj) (tp--replace-match-text function arity match obj idx) (setq idx (1+ idx))) property value object start end))) (provide 'tp-search) ;;; tp-search.el ends here