diff --git a/.github/workflows/emacs-checks.yml b/.github/workflows/emacs-checks.yml new file mode 100644 index 00000000..54729b62 --- /dev/null +++ b/.github/workflows/emacs-checks.yml @@ -0,0 +1,119 @@ +name: Emacs Plugin CI + +on: + push: + paths: + - Eask + - rustowl.el + - emacs-tests/ + - .github/workflows/emacs-checks.yml + branches: + - main + pull_request: + paths: + - Eask + - rustowl.el + - emacs-tests/ + - .github/workflows/emacs-checks.yml + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Emacs + uses: jcs090218/setup-emacs@master + with: + version: 28.1 + + - name: Set up Eask (snapshot) + uses: emacs-eask/setup-eask@master + with: + version: 'snapshot' + + - name: Setup Rustowl + run: | + ./scripts/build/toolchain cargo build --release + ./scripts/build/toolchain cargo install --path . --locked + + - name: Lint Regexps + run: eask lint regexps + + - name: Lint Package + run: eask lint package + + - name: Lint License + run: eask lint license + + - name: Lint Keywords + run: eask lint keywords + + - name: Lint Indent + run: eask lint indent + + - name: Lint Checkdocs + run: eask lint checkdoc + + - name: Lint Declare + run: eask lint declare + + - name: Lint elisp-lint + run: eask lint elisp-lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Emacs + uses: jcs090218/setup-emacs@master + with: + version: 28.1 + + - name: Set up Eask (snapshot) + uses: emacs-eask/setup-eask@master + with: + version: 'snapshot' + + - name: Setup Rustowl + run: | + ./scripts/build/toolchain cargo build --release + ./scripts/build/toolchain cargo install --path . --locked + + - name: Run Tests + run: | + eask install-deps + eask run script test + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Emacs + uses: jcs090218/setup-emacs@master + with: + version: 28.1 + + - name: Setup Rustowl + run: | + ./scripts/build/toolchain cargo build --release + ./scripts/build/toolchain cargo install --path . --locked + + - name: Setup Eask + uses: emacs-eask/setup-eask@master + with: + version: 'snapshot' + + - name: Build And Compile + run: | + eask package + eask install + eask compile diff --git a/.gitignore b/.gitignore index 275ddc56..4176361a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ man/ memory-bank/ security-logs/ benchmark-summary.* +.eask/ \ No newline at end of file diff --git a/Eask b/Eask new file mode 100644 index 00000000..e7b1dd4f --- /dev/null +++ b/Eask @@ -0,0 +1,20 @@ +;; -*- mode: eask; lexical-binding: t -*- + +(package "rustowl" + "0.3.4" + "Visualize Ownership and Lifetimes in Rust") + +(website-url "https://github.com/cordx56/rustowl") +(keywords "lifetime" "ownership" "rust" "visualization" "tools") + +(package-file "rustowl.el") + +(script "test" "eask test ert emacs-tests/rustowl-test.el") + +(source "gnu") +(source "melpa") + +(depends-on "emacs" "28.1") +(depends-on "lsp-mode" "9.0.0") + +(setq network-security-level 'low) ; see https://github.com/jcs090218/setup-emacs-windows/issues/156#issuecomment-932956432 diff --git a/emacs-tests/rustowl-test.el b/emacs-tests/rustowl-test.el new file mode 100644 index 00000000..047376f5 --- /dev/null +++ b/emacs-tests/rustowl-test.el @@ -0,0 +1,292 @@ +;;; rustowl-test.el --- ERT tests for rustowl.el -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'rustowl) + +;; Test rustowl-line-number-at-pos +(ert-deftest rustowl-test-line-number-at-pos () + (with-temp-buffer + (insert "line1\nline2\nline3") + (goto-char (point-min)) + (should (= (rustowl-line-number-at-pos) 0)) + (forward-line 1) + (should (= (rustowl-line-number-at-pos) 1)) + (forward-line 1) + (should (= (rustowl-line-number-at-pos) 2)))) + +;; Test rustowl-current-column +(ert-deftest rustowl-test-current-column () + (with-temp-buffer + (insert "abc\ndef\nghi") + (goto-char (point-min)) + (forward-char 2) + (should (= (rustowl-current-column) 2)) + (forward-line 1) + (should (= (rustowl-current-column) 0)))) + +;; Test rustowl-line-col-to-pos +(ert-deftest rustowl-test-line-col-to-pos () + (with-temp-buffer + (insert "abc\ndef\nghi") + (let ((pos (rustowl-line-col-to-pos 1 2))) + (goto-char pos) + (should (= (rustowl-line-number-at-pos) 1)) + (should (= (rustowl-current-column) 2))))) + +;; Test rustowl-underline and rustowl-clear-overlays +(ert-deftest rustowl-test-underline-and-clear () + (with-temp-buffer + (insert "abcdef") + (let ((ov (rustowl-underline 1 4 "#ff0000"))) + (should (overlayp ov)) + (should (memq ov rustowl-overlays)) + (should + (equal + (overlay-get ov 'face) + '(:underline (:color "#ff0000" :style wave)))) + (rustowl-clear-overlays) + (should (null rustowl-overlays))))) + +;; Test rustowl-underline edge cases +(ert-deftest rustowl-test-underline-edge-cases () + (with-temp-buffer + (insert "abcdef") + ;; start == end: should still create a zero-length overlay + (let ((ov (rustowl-underline 2 2 "#00ff00"))) + (should (overlayp ov)) + (should (= (overlay-start ov) 2)) + (should (= (overlay-end ov) 2))) + ;; start > end: should still create an overlay, but Emacs overlays swap start/end + (let ((ov (rustowl-underline 4 2 "#00ff00"))) + (should (overlayp ov)) + (should (= (overlay-start ov) 2)) + (should (= (overlay-end ov) 4))) + ;; out-of-bounds: should not error, overlay will be clamped + (let ((ov (rustowl-underline -5 100 "#00ff00"))) + (should (overlayp ov)) + (should (<= (overlay-start ov) (point-max))) + (should (>= (overlay-start ov) (point-min))) + (should (<= (overlay-end ov) (point-max))) + (should (>= (overlay-end ov) (point-min)))))) + +;; Test rustowl-clear-overlays when overlays list is empty +(ert-deftest rustowl-test-clear-overlays-empty () + (let ((rustowl-overlays nil)) + (should-not (rustowl-clear-overlays)) + (should (null rustowl-overlays)))) + +;; Test timer logic: rustowl-reset-cursor-timer, rustowl-enable-cursor, rustowl-disable-cursor +(ert-deftest rustowl-test-timer-logic () + (let ((rustowl-cursor-timer nil) + (rustowl-cursor-timeout 0.01) + (called nil)) + (cl-letf (((symbol-function 'run-with-idle-timer) + (lambda (_secs _repeat function &rest _args) + (setq called t) + 'fake-timer)) + ((symbol-function 'cancel-timer) + (lambda (_timer) (setq called 'cancelled))) + ((symbol-function 'rustowl-clear-overlays) + (lambda () (setq called 'cleared)))) + (rustowl-reset-cursor-timer) + (should (or (eq called t) (eq called 'cleared))) + (setq rustowl-cursor-timer 'fake-timer) + (rustowl-disable-cursor) + (should (or (eq called 'cancelled) (eq called 'cleared))) + (let ((added nil)) + (cl-letf (((symbol-function 'add-hook) + (lambda (hook fn &optional _depth local) + (setq added (list hook fn local))))) + (rustowl-enable-cursor) + (should + (equal + added + '(post-command-hook rustowl-reset-cursor-timer t)))))))) + +;; Test idempotency of rustowl-enable-cursor and rustowl-disable-cursor +(ert-deftest rustowl-test-enable-disable-idempotent () + (let ((add-count 0) + (remove-count 0)) + (cl-letf (((symbol-function 'add-hook) + (lambda (hook fn &optional _depth _local) + (cl-incf add-count))) + ((symbol-function 'remove-hook) + (lambda (hook fn &optional _local) + (cl-incf remove-count)))) + (rustowl-enable-cursor) + (rustowl-enable-cursor) + (should (>= add-count 2)) + (rustowl-disable-cursor) + (rustowl-disable-cursor) + (should (>= remove-count 2))))) + +;; Test rustowl-reset-cursor-timer when timer is nil +(ert-deftest rustowl-test-reset-cursor-timer-nil () + (let ((rustowl-cursor-timer nil) + (called nil)) + (cl-letf (((symbol-function 'cancel-timer) + (lambda (_timer) (setq called t))) + ((symbol-function 'rustowl-clear-overlays) + (lambda () (setq called 'cleared))) + ((symbol-function 'run-with-idle-timer) + (lambda (_timeout _repeat fn &rest _args) + 'fake-timer))) + (should (not called)) + (rustowl-reset-cursor-timer) + (should (not (eq called t))) + (should (or (eq called 'cleared) (null called)))))) + +;; Test rustowl-line-col-to-pos with out-of-bounds line/col +(ert-deftest rustowl-test-line-col-to-pos-out-of-bounds () + (with-temp-buffer + (insert "abc\ndef\nghi") + ;; Negative line/col should signal error + (should-error (rustowl-line-col-to-pos -1 -1)) + ;; Test boundary case: (0, 0) should be valid per LSP protocol + (should (rustowl-line-col-to-pos 0 0)) + ;; Line past end + (should (= (rustowl-line-col-to-pos 100 0) (point-max))) + ;; Col past end of line + (goto-char (rustowl-line-col-to-pos 0 100)) + (should (>= (point) (point-min))) + (should (<= (point) (point-max))))) + +;; Test rustowl-cursor overlays (mocking lsp-request-async) +(ert-deftest rustowl-test-cursor-overlays () + (let ((called nil) + (response + (let ((ht (make-hash-table :test 'equal))) + (let* ((deco1 (make-hash-table :test 'equal)) + (range1 (make-hash-table :test 'equal)) + (start1 (make-hash-table :test 'equal)) + (end1 (make-hash-table :test 'equal)) + (deco2 (make-hash-table :test 'equal)) + (range2 (make-hash-table :test 'equal)) + (start2 (make-hash-table :test 'equal)) + (end2 (make-hash-table :test 'equal))) + (puthash "line" 0 start1) + (puthash "character" 0 start1) + (puthash "line" 0 end1) + (puthash "character" 3 end1) + (puthash "start" start1 range1) + (puthash "end" end1 range1) + (puthash "type" "lifetime" deco1) + (puthash "range" range1 deco1) + (puthash "overlapped" nil deco1) + (puthash "line" 0 start2) + (puthash "character" 4 start2) + (puthash "line" 0 end2) + (puthash "character" 6 end2) + (puthash "start" start2 range2) + (puthash "end" end2 range2) + (puthash "type" "imm_borrow" deco2) + (puthash "range" range2 deco2) + (puthash "overlapped" nil deco2) + (puthash "decorations" (vector deco1 deco2) ht) + ht)))) + (with-temp-buffer + (insert "abcdef") + (let ((lsp-mode t)) + (cl-letf (((symbol-function 'lsp-workspaces) + (lambda () '(fake-workspace))) + ((symbol-function 'lsp-request-async) + (lambda (_method _params cb &rest _args) + (funcall cb response) + (setq called t))) + ((symbol-function 'rustowl-underline) + (lambda (start end color) + (setq called (list start end color)) + (make-overlay start end)))) + (rustowl-cursor + '(:position + (:line 0 :character 0) + :document (:uri "file:///fake"))) + (should called)))))) + +;; Test rustowl-cursor overlays for all type branches and overlapped +(ert-deftest rustowl-test-cursor-overlays-all-types () + (let ((called-types '())) + (let* ((make-deco + (lambda (type &optional overlapped) + (let ((deco (make-hash-table :test 'equal)) + (range (make-hash-table :test 'equal)) + (start (make-hash-table :test 'equal)) + (end (make-hash-table :test 'equal))) + (puthash "line" 0 start) + (puthash "character" 0 start) + (puthash "line" 0 end) + (puthash "character" 2 end) + (puthash "start" start range) + (puthash "end" end range) + (puthash "type" type deco) + (puthash "range" range deco) + (puthash "overlapped" overlapped deco) + deco))) + (response + (let ((ht (make-hash-table :test 'equal))) + (puthash + "decorations" + (vector + (funcall make-deco "lifetime") + (funcall make-deco "imm_borrow") + (funcall make-deco "mut_borrow") + (funcall make-deco "move") + (funcall make-deco "call") + (funcall make-deco "outlive") + (funcall make-deco "lifetime" t)) ; overlapped + ht) + ht))) + (with-temp-buffer + (insert "abcdef") + (let ((lsp-mode t)) + (cl-letf (((symbol-function 'lsp-workspaces) + (lambda () '(fake-workspace))) + ((symbol-function 'lsp-request-async) + (lambda (_method _params cb &rest _args) + (funcall cb response))) + ((symbol-function 'rustowl-underline) + (lambda (_start _end color) + (push color called-types) + (make-overlay 1 2)))) + (rustowl-cursor + '(:position + (:line 0 :character 0) + :document (:uri "file:///fake"))) + ;; Should get all colors except for the overlapped one + (should (member "#00cc00" called-types)) ; lifetime + (should (member "#0000cc" called-types)) ; imm_borrow + (should (member "#cc00cc" called-types)) ; mut_borrow + (should (member "#cccc00" called-types)) ; move/call + (should (member "#cc0000" called-types)) ; outlive + ;; Should not call underline for overlapped + (should + (= (length + (cl-remove-if-not + (lambda (c) (equal c "#00cc00")) called-types)) + 1))))))) + + ;; Test rustowl-cursor-call (mocking buffer and lsp) + (ert-deftest rustowl-test-cursor-call () + (let ((called nil)) + (with-temp-buffer + (insert "abc\ndef") + (goto-char (point-min)) + (cl-letf (((symbol-function 'rustowl-line-number-at-pos) + (lambda () 0)) + ((symbol-function 'rustowl-current-column) + (lambda () 1)) + ((symbol-function 'lsp--buffer-uri) + (lambda () "file:///fake")) + ((symbol-function 'rustowl-cursor) + (lambda (params) (setq called params)))) + (rustowl-cursor-call) + (should + (equal + called + '(:position + (:line 0 :character 1) + :document (:uri "file:///fake"))))))))) +(provide 'rustowl-test) +;;; rustowl-test.el ends here diff --git a/rustowl.el b/rustowl.el index a7a57cbd..b38278b2 100644 --- a/rustowl.el +++ b/rustowl.el @@ -3,22 +3,23 @@ ;; Copyright (C) cordx56 ;; Author: cordx56 -;; Keywords: tools +;; Keywords: tools lifetime ownership visualization rust -;; Version: 0.2.0 -;; Package-Requires: ((emacs "24.1") (lsp-mode "9.0.0")) +;; Version: 0.3.4 +;; Package-Requires: ((emacs "28.1") (lsp-mode "9.0.0")) ;; URL: https://github.com/cordx56/rustowl ;; SPDX-License-Identifier: MPL-2.0 ;;; Commentary: +;; Visualize Ownership and Lifetimes in Rust. ;;; Code: (require 'lsp-mode) (defgroup rustowl () - "Visualize Ownership and Lifetimes in Rust" + "Visualize Ownership and Lifetimes in Rust." :group 'tools :prefix "rustowl-" :link '(url-link "https://github.com/cordx56/rustowl")) @@ -27,17 +28,18 @@ (with-eval-after-load 'lsp-mode (lsp-register-client (make-lsp-client - :new-connection (lsp-stdio-connection '("rustowl")) - :major-modes '(rust-mode rust-ts-mode rustic-mode) - :server-id 'rustowl - :priority -1 - :add-on? t))) + :new-connection + (lsp-stdio-connection '("rustowl")) + :major-modes + '(rust-mode rust-ts-mode rustic-mode) + :server-id 'rustowl + :priority -1 + :add-on? t))) ;; Analyze on save (defun rustowl--analyze-request () "Send a rustowl/analyze request to the LSP server for the current buffer." - (when (and (bound-and-true-p lsp-mode) - (lsp-workspaces)) + (when (and (bound-and-true-p lsp-mode) (lsp-workspaces)) (lsp-request-async "rustowl/analyze" (make-hash-table) @@ -58,12 +60,10 @@ (add-hook 'rustic-mode-hook #'rustowl-enable-analyze-on-save) (defun rustowl-cursor (params) - "Send rustowl/cursor request if LSP is active in this buffer." - (when (and (bound-and-true-p lsp-mode) - (lsp-workspaces)) + "Request and visualize Rust ownership/lifetime overlays for PARAMS." + (when (and (bound-and-true-p lsp-mode) (lsp-workspaces)) (lsp-request-async - "rustowl/cursor" - params + "rustowl/cursor" params (lambda (response) (let ((decorations (gethash "decorations" response))) (mapc @@ -77,100 +77,116 @@ (gethash "character" start))) (end-pos (rustowl-line-col-to-pos - (gethash "line" end) - (gethash "character" end))) + (gethash "line" end) (gethash "character" end))) (overlapped (gethash "overlapped" deco))) - (if (not overlapped) - (cond - ((equal type "lifetime") - (rustowl-underline start-pos end-pos "#00cc00")) - ((equal type "imm_borrow") - (rustowl-underline start-pos end-pos "#0000cc")) - ((equal type "mut_borrow") - (rustowl-underline start-pos end-pos "#cc00cc")) - ((or (equal type "move") (equal type "call")) - (rustowl-underline start-pos end-pos "#cccc00")) - ((equal type "outlive") - (rustowl-underline start-pos end-pos "#cc0000")))))) + (unless overlapped + (cond + ((equal type "lifetime") + (rustowl-underline start-pos end-pos "#00cc00")) + ((equal type "imm_borrow") + (rustowl-underline start-pos end-pos "#0000cc")) + ((equal type "mut_borrow") + (rustowl-underline start-pos end-pos "#cc00cc")) + ((or (equal type "move") (equal type "call")) + (rustowl-underline start-pos end-pos "#cccc00")) + ((equal type "outlive") + (rustowl-underline start-pos end-pos "#cc0000")))))) decorations))) :mode 'current))) - (defun rustowl-line-number-at-pos () + "Return the line number at point." (save-excursion (goto-char (point)) (count-lines (point-min) (line-beginning-position)))) + (defun rustowl-current-column () + "Return the current column at point." (save-excursion (let ((start (point))) (move-beginning-of-line 1) (- start (point))))) (defun rustowl-cursor-call () - (when (and (bound-and-true-p lsp-mode) - (lsp-workspaces)) - (let* ((line (rustowl-line-number-at-pos)) - (column (rustowl-current-column)) - (uri (lsp--buffer-uri)) - (pos (let ((ht (make-hash-table :test 'equal))) - (puthash "line" line ht) - (puthash "character" column ht) - ht)) - (doc (let ((ht (make-hash-table :test 'equal))) - (puthash "uri" uri ht) - ht)) - (params (let ((ht (make-hash-table :test 'equal))) - (puthash "position" pos ht) - (puthash "document" doc ht) - ht))) - (rustowl-cursor params)))) + "Call RustOwl for current cursor position." + (let ((line (rustowl-line-number-at-pos)) + (column (rustowl-current-column)) + (uri (lsp--buffer-uri))) + (rustowl-cursor + `(:position + (:line ,line :character ,column) + :document (:uri ,uri))))) ;;;###autoload -(defvar rustowl-cursor-timer nil) +(defvar rustowl-cursor-timer nil + "Timer object for rustowl cursor overlays.") + ;;;###autoload (defvar rustowl-cursor-timeout 2.0) ;;;###autoload (defun rustowl-reset-cursor-timer () + "Reset RustOwl's idle timer for overlays." (when rustowl-cursor-timer (cancel-timer rustowl-cursor-timer)) (rustowl-clear-overlays) (setq rustowl-cursor-timer - (run-with-idle-timer rustowl-cursor-timeout nil #'rustowl-cursor-call))) + (run-with-idle-timer + rustowl-cursor-timeout nil #'rustowl-cursor-call))) ;;;###autoload -(defun enable-rustowl-cursor () +(defun rustowl-enable-cursor () + "Enable RustOwl overlay updates on cursor move." (add-hook 'post-command-hook #'rustowl-reset-cursor-timer nil t)) ;;;###autoload -(defun disable-rustowl-cursor () +(defun rustowl-disable-cursor () + "Disable RustOwl overlay updates." (remove-hook 'post-command-hook #'rustowl-reset-cursor-timer t) (when rustowl-cursor-timer (cancel-timer rustowl-cursor-timer) (setq rustowl-cursor-timer nil))) ;; Automatically enable cursor-based highlighting for Rust buffers -(add-hook 'rust-mode-hook #'enable-rustowl-cursor) -(add-hook 'rust-ts-mode-hook #'enable-rustowl-cursor) -(add-hook 'rustic-mode-hook #'enable-rustowl-cursor) +(add-hook 'rust-mode-hook #'rustowl-enable-cursor) +(add-hook 'rust-ts-mode-hook #'rustowl-enable-cursor) +(add-hook 'rustic-mode-hook #'rustowl-enable-cursor) ;; RustOwl visualization (defun rustowl-line-col-to-pos (line col) + "Convert LINE and COL to buffer position. +LINE and COL are 0-based (LSP compatible); if either is negative (< 0), signal an error. +If LINE is past the last line, return (point-max). +If COL is past end of line, clamp to end of line." + (when (or (< line 0) (< col 0)) + (error "rustowl-line-col-to-pos: negative line or column")) (save-excursion (goto-char (point-min)) - (forward-line line) - (move-to-column col) - (point))) - -(defvar rustowl-overlays nil) + (let ((max-line (line-number-at-pos (point-max)))) + (if (>= line max-line) + (point-max) + (forward-line line) + (let ((bol (point)) + (eol (line-end-position))) + (goto-char bol) + (forward-char (min col (- eol bol))) + (point)))))) + +(defvar rustowl-overlays nil + "List of currently active RustOwl overlays.") (defun rustowl-underline (start end color) - (let ((overlay (make-overlay start end))) - (overlay-put overlay 'face `(:underline (:color ,color :style wave))) + "Underline region from START to END with COLOR." + (let* ((s (max (point-min) (min start end))) + (e (min (point-max) (max start end))) + (overlay (make-overlay s e))) + (overlay-put + overlay 'face `(:underline (:color ,color :style wave))) (push overlay rustowl-overlays) overlay)) (defun rustowl-clear-overlays () + "Remove all RustOwl overlays." (interactive) (mapc #'delete-overlay rustowl-overlays) (setq rustowl-overlays nil)) diff --git a/scripts/bump.sh b/scripts/bump.sh index d07c9613..481ec07c 100755 --- a/scripts/bump.sh +++ b/scripts/bump.sh @@ -67,7 +67,18 @@ else echo "Warning: aur/PKGBUILD-BIN not found" fi -# 5. Create a git tag +# 5. Update Emacs only for stable +if [ "$IS_PRERELEASE" = false ] && [ -f Eask ] && [ -f rustowl.el ]; then + echo "Updating Eask And rustowl..." + $sed -i "4s/.*/$VERSION_WITHOUT_V/" Eask + $sed -i "8s/.*/;; Version: $VERSION_WITHOUT_V/" rustowl.el +elif [ -f Eask ] && [ -f rustowl.el ]; then + echo "Skipping Eask and rustowl.el update for pre-release version" +else + echo "Warning: Eask or rustowl.el not found" +fi + +# 6. Create a git tag echo "Creating git tag: $VERSION" git tag $VERSION