diff --git a/ai-code-input.el b/ai-code-input.el index 28a8aafd..3c93f0c7 100644 --- a/ai-code-input.el +++ b/ai-code-input.el @@ -27,6 +27,7 @@ (declare-function ai-code--prompt-filepath-candidates "ai-code-prompt-mode" ()) (declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt)) (declare-function whisper-run "whisper" ()) +(declare-function treesit-node-parent "treesit" (node)) (eval-when-compile (defvar whisper-after-transcription-hook)) @@ -331,78 +332,221 @@ END-POS defaults to the current '#' position." (string-prefix-p git-root (file-truename file))) file)))))) +(defun ai-code--imenu-symbol-self-entry-p (entry) + "Return non-nil when ENTRY is an Imenu node's self-position entry. +Tree-sitter-backed Imenu uses a whitespace-only name for this entry when a +symbol also contains nested entries." + (and (consp entry) + (stringp (car entry)) + (string-empty-p (string-trim (car entry))) + (ai-code--imenu-item-position (cdr entry)))) + +(defun ai-code--imenu-symbol-self-position (payload) + "Return the self position encoded in nested Imenu PAYLOAD, or nil." + (when-let* ((entry (cl-find-if #'ai-code--imenu-symbol-self-entry-p payload))) + (ai-code--imenu-item-position (cdr entry)))) + +(defun ai-code--imenu-symbol-position-number (position) + "Return numeric buffer position for POSITION, or nil." + (cond + ((integerp position) position) + ((markerp position) (marker-position position)) + (t nil))) + +(defun ai-code--imenu-qualified-name (parent-prefix name) + "Return NAME qualified by PARENT-PREFIX without duplicating a prefix." + (cond + ((or (null parent-prefix) (string-empty-p parent-prefix)) name) + ((or (string= name parent-prefix) + (string-prefix-p (concat parent-prefix ".") name)) + name) + (t (concat parent-prefix "." name)))) + +(defun ai-code--imenu-treesit-qualified-name-at-position (position) + "Return the full Tree-sitter qualified scope name at POSITION, or nil. +Imenu remains the symbol-discovery source; this helper only enriches one +discovered Imenu position with enclosing type information." + (when (and (ai-code--treesit-available-p) + (fboundp 'treesit-node-parent)) + (save-excursion + (goto-char position) + (when-let* ((node (ai-code--treesit-defun-at-point))) + (let* ((node-is-type (ai-code--treesit-class-like-node-p node)) + (function-name + (unless node-is-type + (ai-code--treesit-node-name node))) + (current (if node-is-type + node + (treesit-node-parent node))) + (type-names nil)) + (while current + (when (ai-code--treesit-class-like-node-p current) + (when-let* ((name (ai-code--treesit-node-name current))) + (push name type-names))) + (setq current (treesit-node-parent current))) + (let ((parts (append type-names + (when function-name (list function-name))))) + (when parts + (mapconcat #'identity parts ".")))))))) + +(defun ai-code--imenu-symbol-record (name payload parent-prefix &optional position) + "Build a symbol record from Imenu NAME and PAYLOAD under PARENT-PREFIX. +POSITION overrides the position extracted from PAYLOAD." + (when-let* ((symbol (ai-code--normalize-imenu-symbol-name name payload)) + ((not (string-empty-p symbol))) + (pos (or position (ai-code--imenu-item-position payload)))) + (list :name symbol + :qualified (ai-code--imenu-qualified-name parent-prefix symbol) + :position pos))) + +(defun ai-code--imenu-collect-symbol-records (index &optional parent-prefix) + "Collect symbol records from Imenu INDEX while preserving hierarchy. +PARENT-PREFIX is the qualified name of the semantic container. Plain +category/group nodes are traversed without becoming part of that name." + (let (records) + (dolist (item index) + (when (consp item) + (let ((name (car item)) + (payload (cdr item))) + (cond + ((equal item imenu--rescan-item) + nil) + ((ai-code--imenu-symbol-self-entry-p item) + nil) + ((ai-code--imenu-subalist-p payload) + (if-let* ((self-position (ai-code--imenu-symbol-self-position payload)) + (record (ai-code--imenu-symbol-record + name payload parent-prefix self-position))) + (let* ((qualified (plist-get record :qualified)) + (children (cl-remove-if + #'ai-code--imenu-symbol-self-entry-p + payload))) + (push record records) + (setq records + (nconc (ai-code--imenu-collect-symbol-records + children qualified) + records))) + ;; Category nodes such as "Function" or "Class" have no + ;; self-position and therefore do not qualify their children. + (setq records + (nconc (ai-code--imenu-collect-symbol-records + payload parent-prefix) + records)))) + (t + (when-let* ((record (ai-code--imenu-symbol-record + name payload parent-prefix))) + (push record records))))))) + records)) + +(defun ai-code--imenu-record-position-less-p (left right) + "Return non-nil when LEFT appears before RIGHT in the source buffer." + (let ((left-pos (ai-code--imenu-symbol-position-number + (plist-get left :position))) + (right-pos (ai-code--imenu-symbol-position-number + (plist-get right :position)))) + (cond + ((and left-pos right-pos) (< left-pos right-pos)) + (left-pos t) + (right-pos nil) + (t nil)))) + +(defun ai-code--imenu-symbol-header-at-position (position) + "Return the best declaration header available at POSITION. +When Tree-sitter is active, use the existing semantic scope helper for a +precise declaration header. Otherwise return the source line." + (when-let* ((pos (ai-code--imenu-symbol-position-number position)) + ((<= (point-min) pos)) + ((<= pos (point-max)))) + (save-excursion + (goto-char pos) + (let ((scope (when (ai-code--treesit-available-p) + (ignore-errors (ai-code--current-scope-context pos))))) + (or (plist-get scope :function-header) + (plist-get scope :class-header) + (string-trim + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))))))) + +(defun ai-code--imenu-enrich-symbol-record (record) + "Add line and declaration header metadata to Imenu symbol RECORD." + (let* ((position (plist-get record :position)) + (pos (ai-code--imenu-symbol-position-number position))) + (when (and pos (<= (point-min) pos) (<= pos (point-max))) + (when-let* ((qualified + (ai-code--imenu-treesit-qualified-name-at-position pos))) + (setq record (plist-put record :qualified qualified))) + (setq record (plist-put record :line (line-number-at-pos pos))) + (setq record + (plist-put record :header + (ai-code--imenu-symbol-header-at-position pos)))) + record)) + +(defun ai-code--file-symbol-records--imenu (buffer) + "Return Imenu symbol records from BUFFER in source-definition order." + (with-current-buffer buffer + (condition-case nil + (let* ((imenu-auto-rescan t) + (index (imenu--make-index-alist t)) + (records (ai-code--imenu-collect-symbol-records index))) + (setq records (sort records #'ai-code--imenu-record-position-less-p)) + (mapcar #'ai-code--imenu-enrich-symbol-record records)) + (error nil)))) + (defun ai-code--file-symbol-candidates--imenu (buffer) - "Return sorted imenu candidates from BUFFER." - (let (symbols) - (with-current-buffer buffer - (condition-case nil - (let ((imenu-auto-rescan t) - (index (imenu--make-index-alist t))) - (setq symbols (ai-code--flatten-imenu-index index))) - (error nil))) - (sort (delete-dups (cl-remove-if-not #'stringp symbols)) #'string<))) + "Return qualified Imenu symbol candidates from BUFFER in source order." + (delete-dups + (delq nil + (mapcar (lambda (record) (plist-get record :qualified)) + (ai-code--file-symbol-records--imenu buffer))))) (defun ai-code--file-symbol-candidates (file) - "Return function/class symbol candidates from FILE. -When Tree-sitter is available for FILE's buffer, return qualified -names in file-definition order. Otherwise fall back to an -alphabetically sorted imenu list." - (let ((buf (ignore-errors (find-file-noselect file t)))) - (if (and buf - (fboundp 'ai-code--treesit-available-p) - (fboundp 'ai-code--treesit-file-symbols) - (ignore-errors (ai-code--treesit-available-p buf))) - (let ((symbols (ignore-errors (ai-code--treesit-file-symbols buf)))) - (if (and symbols (consp symbols)) - (let ((qualified (mapcar (lambda (s) (plist-get s :qualified)) symbols))) - (delete-dups (cl-remove-if-not #'stringp qualified))) - (ai-code--file-symbol-candidates--imenu buf))) - (when buf - (ai-code--file-symbol-candidates--imenu buf))))) + "Return qualified symbol candidates from FILE using Imenu. +The major mode decides how the Imenu index is produced. In Tree-sitter modes +this naturally consumes Tree-sitter-backed Imenu without duplicating grammar +knowledge in ai-code." + (when-let* ((buffer (ignore-errors (find-file-noselect file t)))) + (ai-code--file-symbol-candidates--imenu buffer))) (defun ai-code--choose-symbol-annotation-alist (symbols-or-buffer) - "Return annotation alist for Tree-sitter SYMBOLS-OR-BUFFER. -Each element is (QUALIFIED . \" HEADER line N\")." - (let ((symbols (if (bufferp symbols-or-buffer) - (when (and (fboundp 'ai-code--treesit-available-p) - (fboundp 'ai-code--treesit-file-symbols) - (ignore-errors (ai-code--treesit-available-p symbols-or-buffer))) - (ignore-errors (ai-code--treesit-file-symbols symbols-or-buffer))) + "Return completion annotations for SYMBOLS-OR-BUFFER. +SYMBOLS-OR-BUFFER is either a list of Imenu symbol records or a buffer. +Each result maps a qualified name to a declaration header and line number." + (let ((records (if (bufferp symbols-or-buffer) + (ai-code--file-symbol-records--imenu symbols-or-buffer) symbols-or-buffer))) - (when (and symbols (consp symbols)) - (mapcar (lambda (s) - (cons (plist-get s :qualified) - (format " %s line %s" - (or (plist-get s :header) "") - (or (plist-get s :line) "")))) - symbols)))) + (delq nil + (mapcar + (lambda (record) + (when-let* ((qualified (plist-get record :qualified))) + (cons qualified + (format " %s line %s" + (or (plist-get record :header) "") + (or (plist-get record :line) ""))))) + records)))) (defun ai-code--choose-symbol-from-file (file) "Prompt user to select a symbol from FILE and return it. -When Tree-sitter symbols are available, show header and line as -annotation via `completion-extra-properties'." - (let* ((buf (ignore-errors (find-file-noselect file t))) - (ts-symbols (when (and buf - (fboundp 'ai-code--treesit-available-p) - (fboundp 'ai-code--treesit-file-symbols) - (ignore-errors (ai-code--treesit-available-p buf))) - (ignore-errors (ai-code--treesit-file-symbols buf)))) - (candidates (if (and ts-symbols (consp ts-symbols)) - (delete-dups - (cl-remove-if-not #'stringp - (mapcar (lambda (s) (plist-get s :qualified)) ts-symbols))) - (when buf (ai-code--file-symbol-candidates--imenu buf)))) - (annotation-alist (when ts-symbols - (ai-code--choose-symbol-annotation-alist ts-symbols)))) - (when candidates - (let ((completion-extra-properties - (when annotation-alist - (list :annotation-function - (lambda (cand) - (cdr (assoc cand annotation-alist))))))) - (condition-case nil - (completing-read "Symbol: " candidates nil nil) - (quit nil)))))) +Symbol discovery always goes through Imenu. Header and line metadata are +shown through `completion-extra-properties'." + (when-let* ((buffer (ignore-errors (find-file-noselect file t))) + (records (ai-code--file-symbol-records--imenu buffer))) + (let* ((candidates + (delete-dups + (delq nil + (mapcar (lambda (record) (plist-get record :qualified)) + records)))) + (annotation-alist + (ai-code--choose-symbol-annotation-alist records))) + (when candidates + (let ((completion-extra-properties + (when annotation-alist + (list :annotation-function + (lambda (candidate) + (cdr (assoc candidate annotation-alist))))))) + (condition-case nil + (completing-read "Symbol: " candidates nil nil) + (quit nil))))))) (defun ai-code--comment-filepath-capf () "Provide completion candidates for @file paths inside comments." diff --git a/ai-code-utils.el b/ai-code-utils.el index 6fed1819..90b8b189 100644 --- a/ai-code-utils.el +++ b/ai-code-utils.el @@ -34,7 +34,6 @@ (declare-function treesit-node-parent "treesit" (node)) (declare-function treesit-node-children "treesit" (node &optional named)) (declare-function treesit-node-child-by-field-name "treesit" (node field-name)) -(declare-function treesit-parser-root-node "treesit" (parser)) (defvar ai-code--repo-context-info (make-hash-table :test #'equal) "Hash table storing context info lists per Git repository root.") @@ -278,90 +277,6 @@ Otherwise, return the first source line of NODE." (text (buffer-substring-no-properties start end))) (string-trim text)))) -(defconst ai-code--treesit-defun-node-types - '("function_definition" "function_declaration" "method_definition" - "method_declaration" "function_item" "method_item" "function" "method" - "arrow_function" "generator_function" "generator_function_declaration" - "constructor" "constructor_declaration" "procedure_definition" "procedure" - "func_definition" "async_function_definition") - "Tree-sitter node types that define a function or method.") - -(defun ai-code--treesit-defun-node-p (node-type) - "Return non-nil when NODE-TYPE represents a function-like definition." - (and (stringp node-type) - (member node-type ai-code--treesit-defun-node-types))) - -(defun ai-code--treesit-symbol-node-p (node-type) - "Return non-nil when NODE-TYPE is a class-like or function-like symbol." - (or (ai-code--treesit-enclosing-type-node-p node-type) - (ai-code--treesit-defun-node-p node-type))) - -(defun ai-code--treesit--qualified-name (node name) - "Return qualified name for NODE with base NAME. -Prefix with the full enclosing type path." - (let* ((enclosing-names - (let ((names nil) - (cur (treesit-node-parent node))) - (while cur - (when (ai-code--treesit-class-like-node-p cur) - (when-let ((n (ai-code--treesit-node-name cur))) - (push n names))) - (setq cur (treesit-node-parent cur))) - names)) - (prefix (when enclosing-names - (mapconcat #'identity enclosing-names ".")))) - (if (and prefix (not (string-empty-p prefix))) - (concat prefix "." name) - name))) - -(defun ai-code--treesit--symbol-entry (node node-type name) - "Build a symbol plist for NODE of NODE-TYPE with NAME." - (let* ((qualified (ai-code--treesit--qualified-name node name)) - (header (ai-code--treesit-node-header node)) - (start (ignore-errors (treesit-node-start node))) - (end (ignore-errors (treesit-node-end node))) - (line (when (and start (integerp start)) - (line-number-at-pos start))) - (range (when (and (integerp start) (integerp end)) - (cons start end)))) - (list :name name - :qualified qualified - :type node-type - :header header - :range range - :line line - :node node))) - -(defun ai-code--treesit--collect-symbols (root) - "Collect symbol plists from Tree-sitter ROOT node in file order." - (let ((stack (list root)) - (result nil)) - (while stack - (let ((node (pop stack))) - (when node - (let ((node-type (ignore-errors (treesit-node-type node)))) - (when (and node-type (ai-code--treesit-symbol-node-p node-type)) - (when-let ((name (ai-code--treesit-node-name node))) - (push (ai-code--treesit--symbol-entry node node-type name) result))) - (let ((children (ignore-errors (treesit-node-children node t)))) - (when children - (dolist (child (reverse children)) - (push child stack)))))))) - (nreverse result))) - -(defun ai-code--treesit-file-symbols (&optional buffer) - "Return Tree-sitter symbol plists for BUFFER in file-definition order. -BUFFER defaults to `current-buffer'. Each plist contains -`:qualified', `:name', `:type', `:header', `:range' and `:line'. -Return nil when Tree-sitter is unavailable." - (let ((buf (or buffer (current-buffer)))) - (when (ai-code--treesit-available-p buf) - (with-current-buffer buf - (when-let* ((parsers (ignore-errors (treesit-parser-list buf))) - (parser (car parsers)) - (root (ignore-errors (treesit-parser-root-node parser)))) - (ai-code--treesit--collect-symbols root)))))) - (defun ai-code--current-line-semantic-position () "Return the first non-whitespace position on the current line. Return nil when the current line contains only whitespace." diff --git a/test/test_ai-code-input.el b/test/test_ai-code-input.el index e1e5df0f..cfdb5059 100644 --- a/test/test_ai-code-input.el +++ b/test/test_ai-code-input.el @@ -823,9 +823,9 @@ ;; Cleanup (when (file-exists-p test-file) (delete-file test-file))))) -(ert-deftest ai-code-test-file-symbol-candidates-sorted () - "Test that ai-code--file-symbol-candidates returns sorted symbols." - (let ((test-file (expand-file-name "test-sorted.el" temporary-file-directory))) +(ert-deftest ai-code-test-file-symbol-candidates-source-order () + "Test that ai-code--file-symbol-candidates preserves source order." + (let ((test-file (expand-file-name "test-source-order.el" temporary-file-directory))) (unwind-protect (progn (with-temp-file test-file @@ -834,7 +834,7 @@ (insert "(defun beta () nil)\n")) (let ((symbols (ai-code--file-symbol-candidates test-file))) - (should (equal symbols (sort (copy-sequence symbols) #'string<))))) + (should (equal symbols '("zebra" "alpha" "beta"))))) ;; Cleanup (when (file-exists-p test-file) (delete-file test-file))))) @@ -1453,84 +1453,5 @@ (should read-string-called) (should (string= insert-prompt-called "read string long"))))) -;; Spec: Tree-sitter symbol-level # completion — qualified names, file order, annotation, fallback - -(ert-deftest ai-code-test-file-symbol-candidates-treesit-qualified-and-order () - "Use Tree-sitter qualified names in file order when parser is available." - (let ((test-file (expand-file-name "test-treesit-qualified.py" temporary-file-directory))) - (unwind-protect - (progn - (with-temp-file test-file - (insert "def alpha():\n pass\n\n" - "class Service:\n" - " def first(self):\n pass\n" - " def second(self):\n pass\n\n" - "def zebra():\n pass\n")) - ;; Mock Tree-sitter collection to simulate python-ts-mode file order. - (cl-letf (((symbol-function 'ai-code--treesit-available-p) (lambda (&optional _) t)) - ((symbol-function 'ai-code--treesit-file-symbols) - (lambda (&optional _buf) - (list (list :qualified "alpha" :header "def alpha():" :line 1) - (list :qualified "Service" :header "class Service:" :line 3) - (list :qualified "Service.first" :header "def first(self):" :line 4) - (list :qualified "Service.second" :header "def second(self):" :line 6) - (list :qualified "zebra" :header "def zebra():" :line 9)))) - ((symbol-function 'find-file-noselect) - (lambda (file &optional _nowarn _rawfile _wildcards) - ;; Return a temp buffer that satisfies available-p - (let ((buf (generate-new-buffer "*mock-treesit*"))) - (with-current-buffer buf (insert "mock")) buf)))) - (let ((symbols (ai-code--file-symbol-candidates test-file))) - ;; Expect qualified names in file order, not alphabetical sort - (should (equal symbols '("alpha" "Service" "Service.first" "Service.second" "zebra")))))) - (when (file-exists-p test-file) (delete-file test-file))))) - -(ert-deftest ai-code-test-file-symbol-candidates-treesit-fallback-to-imenu () - "Fall back to imenu alphabetical list when Tree-sitter reports no symbols." - (let ((test-file (expand-file-name "test-treesit-fallback.el" temporary-file-directory))) - (unwind-protect - (progn - (with-temp-file test-file - (insert "(defun zebra () nil)\n(defun alpha () nil)\n")) - (cl-letf (((symbol-function 'ai-code--treesit-available-p) (lambda (&optional _) t)) - ((symbol-function 'ai-code--treesit-file-symbols) (lambda (&optional _buf) nil))) - (let ((symbols (ai-code--file-symbol-candidates test-file))) - ;; Fallback path sorts alphabetically - (should (equal symbols '("alpha" "zebra")))))) - (when (file-exists-p test-file) (delete-file test-file))))) - -(ert-deftest ai-code-test-choose-symbol-annotation () - "Choose-symbol provides annotation with header and line." - (let ((test-file (expand-file-name "test-choose-annot.py" temporary-file-directory))) - (unwind-protect - (progn - (with-temp-file test-file - (insert "def alpha():\n pass\n")) - (let (captured-candidates captured-extra) - (cl-letf (((symbol-function 'ai-code--treesit-available-p) (lambda (&optional _) t)) - ((symbol-function 'ai-code--treesit-file-symbols) - (lambda (&optional _buf) - (list (list :qualified "Service.first" :header "def first(self):" :line 10) - (list :qualified "zebra" :header "def zebra():" :line 20)))) - ((symbol-function 'find-file-noselect) - (lambda (file &optional _nowarn _rawfile _wildcards) - (generate-new-buffer "*mock-annot*"))) - ((symbol-function 'completing-read) - (lambda (prompt candidates &rest args) - (setq captured-candidates candidates) - (when (boundp 'completion-extra-properties) - (setq captured-extra completion-extra-properties)) - (let ((ann-fn (plist-get completion-extra-properties :annotation-function))) - (should ann-fn) - (should (string-match-p "def first" (funcall ann-fn "Service.first"))) - (should (string-match-p "10" (funcall ann-fn "Service.first"))) - (should (string-match-p "def zebra" (funcall ann-fn "zebra")))) - "Service.first"))) - (should (string= "Service.first" (ai-code--choose-symbol-from-file test-file))) - (should (equal captured-candidates '("Service.first" "zebra"))) - (should captured-extra) - (should (plist-get captured-extra :annotation-function))))) - (when (file-exists-p test-file) (delete-file test-file))))) - (provide 'test_ai-code-input) ;;; test_ai-code-input.el ends here diff --git a/test/test_ai-code-symbol-completion.el b/test/test_ai-code-symbol-completion.el new file mode 100644 index 00000000..5cac9dde --- /dev/null +++ b/test/test_ai-code-symbol-completion.el @@ -0,0 +1,233 @@ +;;; test_ai-code-symbol-completion.el --- Tests for Imenu symbol completion -*- lexical-binding: t; -*- + +;; Author: Kang Tu +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; Tests for @file#symbol discovery through Imenu. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ai-code-input) + +(ert-deftest ai-code-test-imenu-symbol-records-qualified-and-source-order () + "Preserve Imenu hierarchy while ordering symbols by source position." + (with-temp-buffer + (insert "def alpha():\n" + " pass\n\n" + "class Service:\n" + " def first(self):\n" + " pass\n" + " def second(self):\n" + " pass\n\n" + "def zebra():\n" + " pass\n") + (let ((alpha-pos (save-excursion + (goto-char (point-min)) + (search-forward "def alpha") + (line-beginning-position))) + (service-pos (save-excursion + (goto-char (point-min)) + (search-forward "class Service") + (line-beginning-position))) + (first-pos (save-excursion + (goto-char (point-min)) + (search-forward "def first") + (line-beginning-position))) + (second-pos (save-excursion + (goto-char (point-min)) + (search-forward "def second") + (line-beginning-position))) + (zebra-pos (save-excursion + (goto-char (point-min)) + (search-forward "def zebra") + (line-beginning-position)))) + ;; Deliberately group and scramble the index. Source positions should + ;; determine the final order, category names should not qualify it, and + ;; Imenu's special rescan item should be ignored. + (cl-letf (((symbol-function 'imenu--make-index-alist) + (lambda (&optional _noerror) + `(,imenu--rescan-item + ("Class" + ("Service" + (" " . ,service-pos) + ("first" . ,first-pos) + ("second" . ,second-pos))) + ("Function" + ("zebra" . ,zebra-pos) + ("alpha" . ,alpha-pos)))))) + (let* ((records (ai-code--file-symbol-records--imenu + (current-buffer))) + (qualified (mapcar (lambda (record) + (plist-get record :qualified)) + records))) + (should (equal qualified + '("alpha" "Service" "Service.first" + "Service.second" "zebra"))) + (should (equal (plist-get (nth 2 records) :header) + "def first(self):")) + (should (= (plist-get (nth 2 records) :line) 5))))))) + +(ert-deftest ai-code-test-flat-treesit-imenu-gets-qualified-from-position () + "Qualify flat Tree-sitter Imenu method entries from their positions." + (with-temp-buffer + (insert "class Bar:\n def baz(self):\n pass\n") + (let ((class-pos (point-min)) + (method-pos (save-excursion + (goto-char (point-min)) + (forward-line 1) + (point)))) + (cl-letf (((symbol-function 'imenu--make-index-alist) + (lambda (&optional _noerror) + `(("Class" ("Bar" . ,class-pos)) + ("Function" ("baz" . ,method-pos))))) + ((symbol-function 'ai-code--imenu-treesit-qualified-name-at-position) + (lambda (pos) + (if (= pos class-pos) "Bar" "Bar.baz")))) + (should + (equal + (mapcar (lambda (record) (plist-get record :qualified)) + (ai-code--file-symbol-records--imenu (current-buffer))) + '("Bar" "Bar.baz"))))))) + +(ert-deftest ai-code-test-imenu-qualified-name-does-not-double-prefix () + "Do not duplicate a parent prefix when Imenu already qualified NAME." + (should (equal (ai-code--imenu-qualified-name "Outer" "Outer.Inner") + "Outer.Inner"))) + +(ert-deftest ai-code-test-imenu-treesit-qualified-name-preserves-full-type-path () + "Recover a full enclosing type path from one Imenu symbol position." + (with-temp-buffer + (insert "method") + (cl-letf (((symbol-function 'ai-code--treesit-available-p) + (lambda (&optional _buffer) t)) + ((symbol-function 'ai-code--treesit-defun-at-point) + (lambda (&optional _pos) 'run)) + ((symbol-function 'treesit-node-parent) + (lambda (node) + (pcase node + ('run 'inner) + ('inner 'outer) + ('outer 'root) + (_ nil)))) + ((symbol-function 'ai-code--treesit-class-like-node-p) + (lambda (node) (memq node '(inner outer)))) + ((symbol-function 'ai-code--treesit-node-name) + (lambda (node) + (pcase node + ('run "run") + ('inner "Inner") + ('outer "Outer") + (_ nil))))) + (should + (equal (ai-code--imenu-treesit-qualified-name-at-position (point-min)) + "Outer.Inner.run"))))) + +(ert-deftest ai-code-test-imenu-symbol-records-preserve-nested-type-path () + "Build the complete qualified path for nested Imenu containers." + (with-temp-buffer + (insert "class Outer:\n" + " class Inner:\n" + " def run(self):\n" + " pass\n") + (let ((outer-pos (point-min)) + (inner-pos (save-excursion + (goto-char (point-min)) + (forward-line 1) + (point))) + (run-pos (save-excursion + (goto-char (point-min)) + (forward-line 2) + (point)))) + (cl-letf (((symbol-function 'imenu--make-index-alist) + (lambda (&optional _noerror) + `(("Class" + ("Outer" + (" " . ,outer-pos) + ("Inner" + (" " . ,inner-pos) + ("run" . ,run-pos)))))))) + (should + (equal + (mapcar (lambda (record) (plist-get record :qualified)) + (ai-code--file-symbol-records--imenu (current-buffer))) + '("Outer" "Outer.Inner" "Outer.Inner.run"))))))) + +(ert-deftest ai-code-test-imenu-symbol-header-prefers-semantic-scope () + "Use Tree-sitter semantic scope only as enrichment for an Imenu symbol." + (with-temp-buffer + (insert "def run(\n" + " value):\n" + " return value\n") + (let ((run-pos (point-min))) + (cl-letf (((symbol-function 'imenu--make-index-alist) + (lambda (&optional _noerror) + `(("Function" ("run" . ,run-pos))))) + ((symbol-function 'ai-code--treesit-available-p) + (lambda (&optional _buffer) t)) + ((symbol-function 'ai-code--current-scope-context) + (lambda (&optional _pos) + (list :function-header "def run(\n value):")))) + (let ((record (car (ai-code--file-symbol-records--imenu + (current-buffer))))) + (should (equal (plist-get record :qualified) "run")) + (should (equal (plist-get record :header) + "def run(\n value):"))))))) + +(ert-deftest ai-code-test-file-symbol-candidates-use-imenu-with-treesit-active () + "Do not bypass Imenu when a Tree-sitter parser is active." + (let ((buffer (generate-new-buffer " *ai-code-imenu-symbol-test*"))) + (unwind-protect + (with-current-buffer buffer + (insert "def alpha():\n pass\n") + (let ((alpha-pos (point-min))) + (cl-letf (((symbol-function 'find-file-noselect) + (lambda (&rest _args) buffer)) + ((symbol-function 'ai-code--treesit-available-p) + (lambda (&optional _buffer) t)) + ((symbol-function 'imenu--make-index-alist) + (lambda (&optional _noerror) + `(("Function" ("alpha" . ,alpha-pos)))))) + (should (equal (ai-code--file-symbol-candidates "ignored.py") + '("alpha")))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ai-code-test-choose-symbol-annotation-from-imenu-records () + "Show Imenu-derived source metadata through completion annotations." + (let ((buffer (generate-new-buffer " *ai-code-imenu-annotation-test*"))) + (unwind-protect + (with-current-buffer buffer + (insert "def alpha():\n pass\n") + (let ((alpha-pos (point-min)) + captured-candidates + captured-extra) + (cl-letf (((symbol-function 'find-file-noselect) + (lambda (&rest _args) buffer)) + ((symbol-function 'imenu--make-index-alist) + (lambda (&optional _noerror) + `(("Function" ("alpha" . ,alpha-pos))))) + ((symbol-function 'completing-read) + (lambda (_prompt candidates &rest _args) + (setq captured-candidates candidates + captured-extra completion-extra-properties) + "alpha"))) + (should (equal (ai-code--choose-symbol-from-file "ignored.py") + "alpha")) + (should (equal captured-candidates '("alpha"))) + (let ((annotation-function + (plist-get captured-extra :annotation-function))) + (should annotation-function) + (should (string-match-p + "def alpha()" + (funcall annotation-function "alpha"))) + (should (string-match-p + "line 1" + (funcall annotation-function "alpha"))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(provide 'test_ai-code-symbol-completion) +;;; test_ai-code-symbol-completion.el ends here diff --git a/test/test_ai-code-treesit.el b/test/test_ai-code-treesit.el index f4dfb247..153ff56e 100644 --- a/test/test_ai-code-treesit.el +++ b/test/test_ai-code-treesit.el @@ -341,57 +341,5 @@ (lambda () (ai-code--empty-scope-context)))) (should-not (ai-code--current-qualified-scope-name))))) -;; Spec: Symbol-level @# completion — Tree-sitter file symbols -;; Expected: ai-code--treesit-file-symbols returns qualified names in file order with headers -(ert-deftest test-ai-code-treesit--file-symbols-qualified-and-order () - "Collect file symbols with qualified names in file-definition order." - (skip-unless (and (fboundp 'treesit-language-available-p) - (treesit-language-available-p 'python) - (fboundp 'python-ts-mode))) - (with-temp-buffer - (insert "def alpha():\n pass\n\n" - "class Service:\n" - " def first(self):\n pass\n" - " def second(self):\n pass\n\n" - "def zebra():\n pass\n") - (python-ts-mode) - ;; Function under test does not exist yet — RED should fail. - (should (fboundp 'ai-code--treesit-file-symbols)) - (let* ((symbols (ai-code--treesit-file-symbols (current-buffer))) - (qualified (mapcar (lambda (s) (plist-get s :qualified)) symbols))) - ;; File order, not alphabetical: alpha, Service, Service.first, Service.second, zebra - (should (equal qualified '("alpha" "Service" "Service.first" "Service.second" "zebra"))) - ;; Headers and lines present - (let ((first (cl-find "Service.first" symbols :key (lambda (s) (plist-get s :qualified)) :test #'string=))) - (should first) - (should (string-match-p "def first" (plist-get first :header))) - (should (integerp (plist-get first :line))))))) - -(ert-deftest test-ai-code-treesit--file-symbols-empty () - "Return nil or empty list for file with no symbols." - (skip-unless (and (fboundp 'treesit-language-available-p) - (treesit-language-available-p 'python) - (fboundp 'python-ts-mode))) - (with-temp-buffer - (insert "# just comment\nx = 1\n") - (python-ts-mode) - (should (fboundp 'ai-code--treesit-file-symbols)) - (let ((symbols (ai-code--treesit-file-symbols (current-buffer)))) - (should (null symbols))))) - -(ert-deftest test-ai-code-treesit--file-symbols-class-only () - "Collect class symbol without methods." - (skip-unless (and (fboundp 'treesit-language-available-p) - (treesit-language-available-p 'python) - (fboundp 'python-ts-mode))) - (with-temp-buffer - (insert "class Only:\n pass\n") - (python-ts-mode) - (should (fboundp 'ai-code--treesit-file-symbols)) - (let* ((symbols (ai-code--treesit-file-symbols (current-buffer))) - (qualified (mapcar (lambda (s) (plist-get s :qualified)) symbols))) - (should (equal qualified '("Only"))) - (should (string-match-p "class Only" (plist-get (car symbols) :header)))))) - (provide 'test_ai-code-treesit) ;;; test_ai-code-treesit.el ends here