diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..316f3ca --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,6 @@ +;;; Directory Local Variables -*- no-byte-compile: t -*- +((emacs-lisp-mode + (indent-tabs-mode . nil) + (fill-column . 80) + (sentence-end-double-space . t) + (checkdoc-verb-check-experimental-flag . nil))) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fa1ab5..512db71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,15 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - emacs-version: ['28.1', '29.4', 'snapshot'] + emacs-version: ['28.1', '29.4', '30.1', 'snapshot'] steps: - uses: actions/checkout@v4 - uses: purcell/setup-emacs@master diff --git a/.gitignore b/.gitignore index 377f25b..5d6023a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,13 @@ # Undo-tree save-files *.~undo-tree flycheck_*.el + +# Lock files +.#* + +# Auto-save files +\#*\# + +# Package autoloads +*-autoloads.el +*-pkg.el diff --git a/CHANGELOG.org b/CHANGELOG.org new file mode 100644 index 0000000..2b2d8fd --- /dev/null +++ b/CHANGELOG.org @@ -0,0 +1,119 @@ +#+title: Changelog +#+author: Hayden Stanko + +* 0.3.3 — 2026-03-21 + +** Bug fixes +- Fix daemon-mode overwriting persisted workspace name on shutdown :: + In daemon mode, =delete-frame-functions= fires before =kill-emacs-hook=, + destroying the frame's treemacs scope shelf. =--sync-before-persist= + then re-queried =(treemacs-current-workspace)=, which fell back to + the wrong workspace and overwrote the correct =--current-workspace-name=. + Fixed by (1) persisting the name to disk immediately in + =--on-workspace-switch= and (2) removing the re-capture from + =--sync-before-persist=. + +** Tests +- 2 new specs: =--on-workspace-switch= disk round-trip, and + =--sync-before-persist= daemon scenario (wrong treemacs scope does + not overwrite persisted name). +- Total spec count: 104 (up from 98 in 0.3.2). + +* 0.3.2 — 2026-03-21 + +** New features +- =declarative-project-treemacs-cache-file= defcustom :: The cache file + path is now a =defcustom= with framework-specific guidance for Doom + and Spacemacs users. A backward-compat alias preserves the old + internal name. +- =declarative-project-treemacs-reset-cache= :: Interactive command to + reset the workspace cache to a single empty Default workspace. + +** Bug fixes +- Hardened =--read-cache= against corrupted/empty files :: Empty (0-byte) + or malformed cache files now fall back to minimal desired state instead + of erroring. Corrupt files trigger a =display-warning= and auto-reset. + +** Tests +- 8 new cache validation specs: struct integrity, dedup, multi-workspace + project counts, unassign round-trip, corrupt/empty file recovery, setq + form validation, and normalization idempotency. +- Total spec count: 96 (up from 74 in 0.3.1). + +* 0.3.1 — 2026-03-21 + +** Bug fixes +- Fix duplicate workspace assignments when treemacs-mode active :: The + install function's native treemacs code path was firing alongside the + desired-state model, causing duplicate project entries. The native + fallback is now gated behind =(not declarative-project-treemacs-mode)=. + +** New features +- Workspace enforcement via advice :: Two new advice functions + (=after treemacs-switch-workspace= and =after treemacs--restore=) + re-apply desired state whenever treemacs switches or restores + workspaces, preventing external changes from overriding declared + assignments. + +** Tests +- 7 new specs for fallback guard, advice lifecycle, and mode + registration. Total spec count: 74 (up from 52). + +** Code quality +- Added GPL-3.0 license headers to all secondary =.el= and test files. +- Updated =.gitignore= with Emacs lock-file and autoload patterns. + +* 0.3.0 — 2026-03-20 + +** Bug fixes +- Fix =warn= / =display-warning= mismatch :: All warning calls now use + =display-warning= with the ='declarative-project= type symbol, matching + existing test expectations and providing structured warning output. +- Fix treemacs =--unassign-project= nil dereference :: Wrapped workspace + lookup in =when-let= so =(treemacs-workspace->projects ws)= is never called + on a nil workspace. +- Remove tab characters from warning format strings. + +** New features +- =declarative-project-install= :: Public interactive command (alias for + the internal =--install-project=). Keymap binding =C-c C-c i= now points + to the public name. +- =;;;###autoload= cookies :: Added to =declarative-project-mode= and + =org-babel-execute:declarative-project= for proper lazy-loading via + =package.el=. + +** Code quality +- Added =:package-version= to all =defcustom= declarations. +- Expanded Commentary sections with full =.project= file format reference. +- Improved docstrings on minor mode, accessor functions, and public API. +- Deleted legacy ERT test file (=declarative-project-mode-test.el=) that + referenced removed API. +- Added =.dir-locals.el= for development settings. + +** Tests +- 20 new Buttercup specs for =declarative-project-treemacs= module: + =--minimal-desired-state=, =--ensure-desired-state=, =--workspaces-by-name=, + cache round-trip, =--assign-project= (new workspace, existing, dedup), + =--unassign-project= (happy path + nil regression), =--remove-workspace=, + =--prune-invalid-projects=, =--override-workspaces=, + =--assign-declared-project= (hook integration), + =declarative-project-treemacs-mode= (hook add/remove). +- Expanded =test-helper.el= with =cl-defstruct= stubs for + =treemacs-workspace= / =treemacs-project= and =with-treemacs-test-state= + macro. +- Total spec count: 52 (up from 32). + +* 0.2.0 — 2026-03-15 + +** Highlights +- Rewrote core to use hash-table based =project-resources= instead of + =cl-defstruct=. +- Absorbed =treemacs-declarative-workspaces-mode= into + =declarative-project-treemacs.el=. +- Added =ob-declarative-project.el= for Org-Babel integration. +- Added =declarative-project--install-from-content= for programmatic / + Org-Babel use without a =.project= file on disk. +- Switched test framework from ERT to Buttercup (32 specs). +- Replaced =shell-command= with =call-process= for git clone (security). +- Added CI via GitHub Actions, README, and CONTRIBUTING guide. +- Dropped =file-name-parent-directory= for Emacs 28.1 compatibility. diff --git a/CLAUDE.md b/CLAUDE.md index 10b5f9a..b15cc9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,27 @@ Emacs minor mode for declarative project resource management via `.project` file - `declarative-project--apply-treemacs-workspaces-hook` is run during install for treemacs integration - Accessor functions: `declarative-project-workspaces`, `declarative-project-root-directory`, `declarative-project-name` +## Cache Architecture (treemacs integration) + +- **Cache file**: `treemacs-declared-workspaces.el` in `user-emacs-directory` — stores desired workspace structs as a `setq` form +- **Desired-state model**: `.project` installs accumulate workspace/project structs into `--desired-state`; this list is the single source of truth for what treemacs workspaces should contain +- **Relationship to treemacs persist file**: treemacs has its own `treemacs-persist` file written on `kill-emacs-hook`. Our mode takes full control of `treemacs--workspaces`, so both files should converge. + +### 3-layer persistence defense + +- **Layer 1 (pre-empt)**: Sets `(put 'treemacs :state-is-restored t)` on mode enable, preventing `treemacs--maybe-load-workspaces` from ever firing +- **Layer 2 (after-restore advice)**: If `treemacs--restore` fires anyway (e.g. mode enabled late), `:after` advice re-applies desired state via `--override-workspaces` +- **Layer 3 (kill-emacs sync)**: `--sync-before-persist` runs on `kill-emacs-hook` at depth -90 (before treemacs's persist at depth 0), ensuring treemacs writes our desired state to disk + +### treemacs-persp interaction + +Doom's `:ui workspaces` module enables `treemacs-persp`, which sets scope type to `Perspectives` and creates "Perspective " workspaces. We suppress this via `:around` advice on `treemacs-persp--ensure-workspace-exists`. + +**Key design choice**: hooks that fire during normal operation (`treemacs-switch-workspace-hook`, `treemacs-select-functions`, persp-ensure advice) use `--sync-workspace-list` which maintains the workspace list without changing the current workspace. Only mode init and after-restore use `--override-workspaces` which also sets the current workspace. This prevents perspective switches and treemacs focus from resetting the user's workspace selection. + ## Development - Emacs Lisp with `lexical-binding: t` - Package requires Emacs 28.1+ -- Tests: `make test` (uses Buttercup, 25 specs) +- Tests: `make test` (uses Buttercup, 104 specs) - Test runner auto-detects straight.el (Doom) or Cask diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..049fb41 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at system.cuttle@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/Makefile b/Makefile index 58d5fb3..567d2bb 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ test: -l buttercup \ -l test-helper \ -l test-declarative-project \ + -l test-declarative-project-treemacs \ -l test-ob-declarative-project \ -f buttercup-run diff --git a/README.org b/README.org index 2f95acb..499ddb8 100644 --- a/README.org +++ b/README.org @@ -109,6 +109,52 @@ When enabled, project installation creates or updates treemacs workspace entries. Set =declarative-project-treemacs-autoprune= to =nil= to disable automatic removal of stale project entries on mode init. +** Framework-specific configuration + +*** Doom Emacs + +In =packages.el=: +#+begin_src emacs-lisp +(package! declarative-project-mode + :recipe (:host github :repo "cuttlefisch/declarative-project-mode")) +#+end_src + +In =config.el=: +#+begin_src emacs-lisp +(use-package! declarative-project-mode + :config + (after! treemacs + (require 'declarative-project-treemacs) + (setq declarative-project-treemacs-cache-file + (expand-file-name "treemacs-declared-workspaces.el" doom-cache-dir)) + (declarative-project-treemacs-mode 1))) +#+end_src + +*** Spacemacs + +In =dotspacemacs-additional-packages=: +#+begin_src emacs-lisp +(declarative-project-mode + :location (recipe :fetcher github :repo "cuttlefisch/declarative-project-mode")) +#+end_src + +In =dotspacemacs/user-config=: +#+begin_src emacs-lisp +(require 'declarative-project-treemacs) +(setq declarative-project-treemacs-cache-file + (expand-file-name "treemacs-declared-workspaces.el" spacemacs-cache-directory)) +(declarative-project-treemacs-mode 1) +#+end_src + +*** Vanilla Emacs + +#+begin_src emacs-lisp +(require 'declarative-project-treemacs) +(declarative-project-treemacs-mode 1) +;; Cache file defaults to user-emacs-directory; customize if needed: +;; (setq declarative-project-treemacs-cache-file "~/.cache/emacs/treemacs-declared-workspaces.el") +#+end_src + * Spec Reference | Field | Type | Description | @@ -129,12 +175,13 @@ to placing the resource in the project root directory. |--------------------------------------------+---------+---------------------------------------------------| | =declarative-project-auto-install= | =nil= | Auto-run installation when mode activates | | =declarative-project-treemacs-autoprune= | =t= | Remove stale project entries on treemacs mode init | +| =declarative-project-treemacs-cache-file= | =user-emacs-directory= | Path to the workspace cache file | * Development #+begin_src shell cask install -make test # 32 Buttercup specs +make test # 96 Buttercup specs #+end_src See [[file:CONTRIBUTING.org][CONTRIBUTING.org]] for the full development guide. diff --git a/declarative-project-mode-test.el b/declarative-project-mode-test.el deleted file mode 100644 index bfbc224..0000000 --- a/declarative-project-mode-test.el +++ /dev/null @@ -1,56 +0,0 @@ -(require 'ert) -(require 'declarative-project-mode) - -(defun simple-hash-table (&rest pairs) - (let ((le-hash (make-hash-table :test 'equal))) - (dolist (pair pairs) - (puthash (car pair) (cadr pair) le-hash)) - le-hash)) - -(ert-deftest declarative-project--install-project-dependencies-test () - (let ((project (declarative-project--make-declarative-project-with-defaults - :name "test" - :root-directory "/tmp/test" - :deps (list (simple-hash-table - '(src "git@github.com:cuttlefisch/declarative-project-mode.git") - '(dest "test-project"))))) - (declarative-project--clobber t) - (install-target-path "/tmp/test/test-project")) - (declarative-project--prep-target project) - (declarative-project--install-project-dependencies project) - (when-let ((success (should (file-directory-p install-target-path)))) - (delete-directory (declarative-project-root-directory project) t) - success))) - -(ert-deftest declarative-project--copy-local-files-test () - (let ((project (declarative-project--make-declarative-project-with-defaults - :name "test" - :root-directory "/tmp/test" - :local-files (list (simple-hash-table - '(src "./test/local-file.txt") - '(dest "file.txt"))))) - (declarative-project--clobber t) - (expected-resource "/tmp/test/file.txt")) - (declarative-project--prep-target project) - (declarative-project--copy-local-files project) - (when-let ((success (should (file-exists-p expected-resource)))) - (delete-directory (declarative-project-root-directory project) t) - success))) - -(ert-deftest declarative-project--create-symlinks-test () - (let ((project (declarative-project--make-declarative-project-with-defaults - :name "test" - :root-directory "/tmp/test" - :symlinks (list (simple-hash-table - '(targ "./test/symlink-target.txt") - '(link "test-link"))))) - (declarative-project--clobber t) - (expected-resource "/tmp/test/test-link")) - (declarative-project--prep-target project) - (declarative-project--create-symlinks project) - (when-let ((success (should (file-symlink-p expected-resource)))) - (delete-directory (declarative-project-root-directory project) t) - success))) - - -(ert-run-tests-batch-and-exit) diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 21420b3..1a924de 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -2,11 +2,11 @@ ;; ;; Copyright (C) 2023 Hayden Stanko ;; -;; Author: Hayden Stanko -;; Maintainer: Hayden Stanko +;; Author: Hayden Stanko +;; Maintainer: Hayden Stanko ;; Created: January 13, 2023 -;; Modified: March 15, 2026 -;; Version: 0.2.0 +;; Modified: March 21, 2026 +;; Version: 0.3.3 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode ;; Package-Requires: ((emacs "28.1") (yaml "0.5.1")) @@ -26,14 +26,27 @@ ;; ;;; Commentary: ;; -;; Declarative Project mode is a minor mode for managing project resources. The -;; mode is triggered by visiting a directory containing a .project file. The -;; .project file should be in yaml or json format and may contain the following -;; fields "project-name", "required-resources", "deps", "local-files", -;; "symlinks", "treemacs-workspaces". +;; Declarative Project mode is a minor mode for managing project resources +;; through declarative `.project' files. The mode activates automatically +;; when visiting a `.project' file (via `find-file-hook'). ;; -;; Keybindings: - `C-c C-c i': Run the install-project command when visiting -;; .project file +;; A `.project' file is a YAML or JSON document that may declare: +;; +;; - `project-name' — human-readable project label (alias: `name') +;; - `root-directory' — project root path (overrides `:dir' / cwd) +;; - `required-resources' — paths that must exist (warnings on missing) +;; - `deps' — git repositories to clone +;; - `local-files' — files/directories to copy into the project +;; - `symlinks' — symbolic links to create +;; - `treemacs-workspaces' — treemacs workspace assignments (alias: +;; `workspaces'; see `declarative-project-treemacs' +;; for full workspace management) +;; +;; Use `declarative-project-install' (bound to `C-c C-c i') to process the +;; spec. Set `declarative-project-auto-install' to run it on mode activation. +;; +;; See the project README for full documentation: +;; https://github.com/cuttlefisch/declarative-project-mode ;; ;;; Code: (require 'json) @@ -42,6 +55,7 @@ (declare-function treemacs-do-create-workspace "treemacs-workspaces" (&optional name)) (declare-function treemacs-find-workspace-by-name "treemacs-workspaces" (name)) (declare-function treemacs-do-add-project-to-workspace "treemacs-workspaces" (path name)) +(defvar treemacs-override-workspace) ;;; --- Customization --- @@ -53,7 +67,8 @@ (defcustom declarative-project-auto-install nil "If non-nil, automatically run installation when mode activates." :type 'boolean - :group 'declarative-project) + :group 'declarative-project + :package-version '(declarative-project-mode . "0.2.0")) ;;; --- Hooks --- @@ -64,18 +79,28 @@ Functions receive a single argument: the project-resources hash table.") ;;; --- Accessor functions --- (defun declarative-project-workspaces (project-resources) - "Return the treemacs-workspaces list from PROJECT-RESOURCES." - (gethash 'treemacs-workspaces project-resources)) + "Return the treemacs-workspaces list from PROJECT-RESOURCES hash table. +The value is a list of workspace name strings, or nil if unset. +Accepts both `treemacs-workspaces' and `workspaces' as key names." + (or (gethash 'treemacs-workspaces project-resources) + (gethash 'workspaces project-resources))) (defun declarative-project-root-directory (project-resources) - "Return the project root directory from PROJECT-RESOURCES." + "Return the project root directory from PROJECT-RESOURCES hash table. +Prefers the explicit `project-root' key (set by the install pipeline); +falls back to `root-directory' from the spec (expanded to absolute), +then to the parent directory of `project-file'." (or (gethash 'project-root project-resources) + (when-let ((rd (gethash 'root-directory project-resources))) + (file-name-as-directory (expand-file-name rd))) (when-let ((pf (gethash 'project-file project-resources))) (file-name-directory pf)))) (defun declarative-project-name (project-resources) - "Return the project name from PROJECT-RESOURCES." - (gethash 'project-name project-resources)) + "Return the project name string from PROJECT-RESOURCES hash table, or nil. +Accepts both `project-name' and `name' as key names." + (or (gethash 'project-name project-resources) + (gethash 'name project-resources))) ;;; --- Core functions --- @@ -84,7 +109,8 @@ Functions receive a single argument: the project-resources hash table.") (when-let ((required-resources (gethash 'required-resources project-resources))) (dolist (resource required-resources) (unless (file-exists-p resource) - (warn "Missing required resource: %s" resource))))) + (display-warning 'declarative-project + (format "Missing required resource: %s" resource)))))) (defun declarative-project--install-project-dependencies (project-resources) "Clone any git dependencies locally in PROJECT-RESOURCES." @@ -106,7 +132,8 @@ Functions receive a single argument: the project-resources hash table.") (list src) (unless (string-empty-p dest) (list dest)))))) (unless (zerop exit-code) - (warn "git clone %s failed with exit code %d" src exit-code)))))))) + (display-warning 'declarative-project + (format "git clone %s failed with exit code %d" src exit-code))))))))) (defun declarative-project--copy-local-files (project-resources) "Copy over any local files in PROJECT-RESOURCES." @@ -124,7 +151,8 @@ Functions receive a single argument: the project-resources hash table.") (message "Copying file %s..." src) (copy-file src full-dest t)) (t - (warn "No such file or directory:\t%s" src))))))) + (display-warning 'declarative-project + (format "No such file or directory: %s" src)))))))) (defun declarative-project--create-symlinks (project-resources) "Create symlinks defined in PROJECT-RESOURCES." @@ -135,23 +163,29 @@ Functions receive a single argument: the project-resources hash table.") (file-name-nondirectory targ))) (full-link (expand-file-name link))) (if (not (file-exists-p targ)) - (warn "No such file or directory:\t%s" targ) + (display-warning 'declarative-project + (format "No such file or directory: %s" targ)) (make-directory (file-name-directory full-link) t) (message "Creating symlink %s -> %s" link targ) (make-symbolic-link targ full-link t)))))) (defun declarative-project--apply-treemacs-workspaces (project-resources) "Add project to any treemacs workspaces listed in PROJECT-RESOURCES." - (when-let ((project-workspaces (gethash 'treemacs-workspaces project-resources)) + (when-let ((project-workspaces (declarative-project-workspaces project-resources)) (root-dir (declarative-project-root-directory project-resources))) (run-hook-with-args 'declarative-project--apply-treemacs-workspaces-hook project-resources) - (when (featurep 'treemacs) + ;; When declarative-project-treemacs-mode is active, the hook above + ;; handles workspace assignment via the desired-state model. Only + ;; fall back to the native treemacs API when the extension mode is off. + (when (and (featurep 'treemacs) + (not (bound-and-true-p declarative-project-treemacs-mode))) (dolist (workspace project-workspaces) - (let ((project-name (or (gethash 'project-name project-resources) workspace))) + (let ((project-name (or (declarative-project-name project-resources) workspace))) (message "Adding project to treemacs workspace: %s" workspace) (treemacs-do-create-workspace workspace) - (treemacs-with-workspace (treemacs-find-workspace-by-name workspace) + (let ((treemacs-override-workspace + (treemacs-find-workspace-by-name workspace))) (treemacs-do-add-project-to-workspace root-dir project-name))))))) @@ -173,9 +207,14 @@ if both parsers fail or if the result is not a hash table." (defun declarative-project--install-from-content (content project-dir &optional extra-keys) "Parse CONTENT (YAML/JSON) and run the install pipeline rooted at PROJECT-DIR. +If the spec contains a `root-directory' key, that path is used as the +project root instead of PROJECT-DIR. EXTRA-KEYS is an alist of additional keys to set in the resource hash." - (let* ((default-directory (file-name-as-directory (expand-file-name project-dir))) - (project-resources (declarative-project--parse-project-file content))) + (let* ((project-resources (declarative-project--parse-project-file content)) + (root-from-spec (when-let ((rd (gethash 'root-directory project-resources))) + (file-name-as-directory (expand-file-name rd)))) + (default-directory (or root-from-spec + (file-name-as-directory (expand-file-name project-dir))))) (puthash 'project-root default-directory project-resources) (dolist (pair extra-keys) (puthash (car pair) (cdr pair) project-resources)) @@ -201,19 +240,33 @@ EXTRA-KEYS is an alist of additional keys to set in the resource hash." (file-name-directory project-file) (list (cons 'project-file project-file))))) +;;;###autoload +(defalias 'declarative-project-install #'declarative-project--install-project + "Parse the .project file in `default-directory' and install all declared resources. +This is the public entry point for project installation.") + ;;; --- Mode definition --- +;;;###autoload (define-minor-mode declarative-project-mode - "Declarative Project mode." + "Minor mode for declarative project resource management. + +Activates automatically when visiting a `.project' file. Provides +`declarative-project-install' (\\[declarative-project-install]) to +parse the spec and install declared resources (dependencies, files, +symlinks, treemacs workspaces). + +See Info node `(declarative-project-mode)' or the project README +for the full `.project' file format." :lighter " DPM" :group 'declarative-project :keymap (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-c i") - #'declarative-project--install-project) + #'declarative-project-install) map) (when (and declarative-project-mode declarative-project-auto-install) - (declarative-project--install-project))) + (declarative-project-install))) (defun declarative-project--maybe-enable () "Enable `declarative-project-mode' if visiting a .project file." diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index d416146..32aaa7c 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -2,17 +2,30 @@ ;; ;; Copyright (C) 2023 Hayden Stanko ;; -;; Author: Hayden Stanko -;; Maintainer: Hayden Stanko +;; Author: Hayden Stanko +;; Maintainer: Hayden Stanko ;; Created: January 14, 2023 -;; Modified: March 15, 2026 -;; Version: 0.2.0 +;; Modified: March 21, 2026 +;; Version: 0.3.3 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode -;; Package-Requires: ((emacs "28.1") (treemacs "2.10") (declarative-project-mode "0.2.0")) +;; Package-Requires: ((emacs "28.1") (treemacs "2.10") (declarative-project-mode "0.3.3")) ;; ;; This file is not part of GNU Emacs. ;; +;; 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. +;; +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . +;; ;;; Commentary: ;; ;; Manage treemacs workspaces via distributed declarative .project files. @@ -39,14 +52,27 @@ "List of desired workspaces and project contents from declared workspaces. Initialized lazily when the mode is enabled.") -(defvar declarative-project-treemacs--cache-file +(defvar declarative-project-treemacs--current-workspace-name nil + "Name of the last-selected workspace, persisted across sessions.") + +(defcustom declarative-project-treemacs-cache-file (expand-file-name "treemacs-declared-workspaces.el" user-emacs-directory) - "File path for caching the declared workspace state.") + "File path for caching the declared workspace state. +Doom Emacs users may want to set this under `doom-cache-dir'. +Spacemacs users may want to use `spacemacs-cache-directory'." + :type 'file + :group 'declarative-project + :package-version '(declarative-project-treemacs . "0.3.2")) + +(defvaralias 'declarative-project-treemacs--cache-file + 'declarative-project-treemacs-cache-file + "Backward-compat alias for the internal name.") (defcustom declarative-project-treemacs-autoprune t "If non-nil, remove invalid projects from cache on mode init." :type 'boolean - :group 'declarative-project) + :group 'declarative-project + :package-version '(declarative-project-treemacs . "0.2.0")) ;;; --- Backward compatibility --- @@ -74,23 +100,94 @@ Initialized lazily when the mode is enabled.") ;;; --- Cache persistence --- +(defun declarative-project-treemacs--normalize-struct (record constructor &rest accessors) + "Re-create RECORD via CONSTRUCTOR using ACCESSORS to extract fields. +Any accessor that fails (e.g. due to a struct layout change) yields nil. +This ensures cached structs are migrated to the current layout." + (apply constructor + (mapcan (lambda (acc) + (let* ((keyword (intern (format ":%s" (car acc)))) + (value (condition-case nil + (funcall (cdr acc) record) + (args-out-of-range nil)))) + (list keyword value))) + accessors))) + +(defun declarative-project-treemacs--normalize-cache () + "Re-create all workspace/project structs to match current definitions. +Handles cache entries written with older struct layouts that may be +missing fields (e.g. `is-disabled?')." + (setq declarative-project-treemacs--desired-state + (mapcar + (lambda (ws) + (declarative-project-treemacs--normalize-struct + ws #'treemacs-workspace->create! + (cons 'name #'treemacs-workspace->name) + (cons 'projects + (lambda (w) + (mapcar + (lambda (proj) + (declarative-project-treemacs--normalize-struct + proj #'treemacs-project->create! + (cons 'name #'treemacs-project->name) + (cons 'path #'treemacs-project->path) + (cons 'path-status #'treemacs-project->path-status) + (cons 'is-disabled? #'treemacs-project->is-disabled?))) + (treemacs-workspace->projects w)))) + (cons 'is-disabled? #'treemacs-workspace->is-disabled?))) + declarative-project-treemacs--desired-state))) + (defun declarative-project-treemacs--save-cache () - "Write current desired state to cache file." + "Write current desired state and current workspace name to cache file." (declarative-project-treemacs--ensure-desired-state) - (with-temp-file declarative-project-treemacs--cache-file + (with-temp-file declarative-project-treemacs-cache-file (let ((standard-output (current-buffer))) (insert ";; -*- no-byte-compile: t -*-\n") (insert ";; declarative-project-treemacs cache — auto-generated\n") (prin1 `(setq declarative-project-treemacs--desired-state - ',declarative-project-treemacs--desired-state))))) + ',declarative-project-treemacs--desired-state)) + (insert "\n") + (prin1 `(setq declarative-project-treemacs--current-workspace-name + ',declarative-project-treemacs--current-workspace-name))))) (defun declarative-project-treemacs--read-cache () - "Read the desired state from the cache file." - (if (file-exists-p declarative-project-treemacs--cache-file) - (load declarative-project-treemacs--cache-file nil t t) + "Read the desired state from the cache file. +After loading, normalizes all structs to the current layout. +If the cache file is missing, corrupted, or empty, falls back to +minimal desired state and re-saves." + (if (and (file-exists-p declarative-project-treemacs-cache-file) + (> (file-attribute-size + (file-attributes declarative-project-treemacs-cache-file)) + 0)) + (condition-case err + (progn + (load declarative-project-treemacs-cache-file nil t t) + (declarative-project-treemacs--normalize-cache)) + (error + (display-warning 'declarative-project + (format "Cache file corrupt, resetting: %s" + (error-message-string err))) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--ensure-desired-state) + (declarative-project-treemacs--save-cache))) (declarative-project-treemacs--ensure-desired-state) (declarative-project-treemacs--save-cache))) +;;;###autoload +(defun declarative-project-treemacs-reset-cache () + "Reset the declared workspace cache to a single empty Default workspace. +Use this to clear stale entries accumulated from old .project files +or babel blocks. After resetting, re-run your declarative-project +installs to repopulate." + (interactive) + (setq declarative-project-treemacs--desired-state nil) + (setq declarative-project-treemacs--current-workspace-name nil) + (declarative-project-treemacs--ensure-desired-state) + (declarative-project-treemacs--save-cache) + (when declarative-project-treemacs-mode + (declarative-project-treemacs--override-workspaces)) + (message "declarative-project-treemacs: cache reset to Default workspace")) + ;;; --- Project management --- (defun declarative-project-treemacs--workspace-memberp (project workspace) @@ -129,12 +226,11 @@ Initialized lazily when the mode is enabled.") (defun declarative-project-treemacs--unassign-project (project workspace) "Remove PROJECT from WORKSPACE in desired state." - (let* ((ws (declarative-project-treemacs--workspaces-by-name workspace)) - (new-projects (cl-remove project - (treemacs-workspace->projects ws) - :test #'string= - :key (lambda (pj) (treemacs-project->name pj))))) - (when ws + (when-let ((ws (declarative-project-treemacs--workspaces-by-name workspace))) + (let ((new-projects (cl-remove project + (treemacs-workspace->projects ws) + :test #'string= + :key (lambda (pj) (treemacs-project->name pj))))) (setf (treemacs-workspace->projects ws) new-projects))) (when declarative-project-treemacs-mode (declarative-project-treemacs--override-workspaces)) @@ -160,9 +256,87 @@ Initialized lazily when the mode is enabled.") ;;; --- Workspace override --- +(declare-function treemacs-current-workspace "treemacs-workspaces" ()) +(declare-function treemacs--invalidate-buffer-project-cache "treemacs-workspaces" ()) +(declare-function treemacs--restore "treemacs-persistence" ()) +(declare-function treemacs-select-window "treemacs" (&optional arg)) +(declare-function treemacs-persp--ensure-workspace-exists "treemacs-persp" ()) + (defun declarative-project-treemacs--override-workspaces () - "Set treemacs workspaces to the desired state." - (setq treemacs--workspaces declarative-project-treemacs--desired-state)) + "Set treemacs workspaces to the desired state. +Also updates treemacs's scope shelf so the current workspace points +to the corresponding object in the desired state list. When the +current workspace name is not found in the desired state (e.g. a +persp-created workspace), falls back to the first desired workspace. +Prevents treemacs from restoring its persist file by setting the +`:state-is-restored' flag after populating the workspace list." + (declarative-project-treemacs--ensure-desired-state) + (setq treemacs--workspaces declarative-project-treemacs--desired-state) + ;; Prevent treemacs--maybe-load-workspaces from overwriting our state. + ;; This mode takes full control of the workspace list, so we + ;; intentionally suppress treemacs's one-time persist-file restore. + (put 'treemacs :state-is-restored t) + ;; Now safe to call treemacs-current-workspace — the lazy-load is a no-op. + (condition-case nil + (let* ((current (treemacs-current-workspace)) + (name (and current (treemacs-workspace->name current))) + (target (or (and declarative-project-treemacs--current-workspace-name + (declarative-project-treemacs--workspaces-by-name + declarative-project-treemacs--current-workspace-name)) + (and name (declarative-project-treemacs--workspaces-by-name name)) + (car declarative-project-treemacs--desired-state)))) + (when (and target (not (eq current target))) + (setf (treemacs-current-workspace) target) + (treemacs--invalidate-buffer-project-cache))) + (error nil)) + ;; Re-render the treemacs buffer so it reflects the updated workspace. + (when (treemacs-get-local-buffer) + (treemacs--consolidate-projects))) + +(defun declarative-project-treemacs--sync-workspace-list () + "Ensure `treemacs--workspaces' contains our desired state. +Unlike `--override-workspaces', this does NOT change the current +workspace in the scope shelf. Use this for hooks that fire during +normal operation (perspective switches, treemacs select, workspace +switch) where we want to maintain the workspace list but let +treemacs or the user control which workspace is active." + (declarative-project-treemacs--ensure-desired-state) + (setq treemacs--workspaces declarative-project-treemacs--desired-state) + (put 'treemacs :state-is-restored t)) + +(defun declarative-project-treemacs--on-select (_reason) + "Re-apply declared workspace list after treemacs window is selected. +Called via `treemacs-select-functions'; REASON is ignored." + (declarative-project-treemacs--sync-workspace-list)) + +(defun declarative-project-treemacs--on-workspace-switch () + "Record current workspace name and sync workspace list. +Used on `treemacs-switch-workspace-hook' to persist the user's +workspace selection across sessions." + (declarative-project-treemacs--sync-workspace-list) + (condition-case nil + (let ((ws (treemacs-current-workspace))) + (when ws + (setq declarative-project-treemacs--current-workspace-name + (treemacs-workspace->name ws)) + (declarative-project-treemacs--save-cache))) + (error nil))) + +(defun declarative-project-treemacs--around-exclusive-display (orig-fn) + "Show treemacs without modifying workspace content when our mode is active. +ORIG-FN is `treemacs-add-and-display-current-project-exclusively'." + (if declarative-project-treemacs-mode + (treemacs-select-window) + (funcall orig-fn))) + +(defun declarative-project-treemacs--around-persp-ensure (orig-fn) + "Suppress persp workspace creation; maintain our workspace list instead. +ORIG-FN is `treemacs-persp--ensure-workspace-exists'. +Only syncs the workspace list — does not change the current workspace, +so perspective switches don't reset the user's workspace selection." + (if declarative-project-treemacs-mode + (declarative-project-treemacs--sync-workspace-list) + (funcall orig-fn))) ;;; --- Hook integration --- @@ -181,6 +355,25 @@ Initialized lazily when the mode is enabled.") (declarative-project-treemacs--override-workspaces) (declarative-project-treemacs--save-cache)))) +;;; --- Persistence defense --- + +(defun declarative-project-treemacs--after-treemacs-restore (&rest _) + "Re-apply desired state after treemacs restores from persist file. +Safety net: if `treemacs--restore' fires despite the pre-emption flag +\(e.g. mode enabled after treemacs started, or another package forces +a restore), this advice ensures our desired state takes precedence." + (when declarative-project-treemacs-mode + (declarative-project-treemacs--override-workspaces))) + +(defun declarative-project-treemacs--sync-before-persist () + "Ensure treemacs persists our desired state, not stale data. +Runs on `kill-emacs-hook' at depth -90 (before treemacs's own +`treemacs--persist' which runs at default depth 0)." + (when declarative-project-treemacs-mode + (declarative-project-treemacs--ensure-desired-state) + (setq treemacs--workspaces declarative-project-treemacs--desired-state) + (declarative-project-treemacs--save-cache))) + ;;; --- Mode definition --- ;;;###autoload @@ -192,14 +385,41 @@ The desired state of workspaces is tracked in a central cache inside `user-emacs-directory'. When enabled, treemacs workspaces are overridden with the declared desired state. -Note: this mode currently takes full control of the treemacs workspace -list and is still experimental." +Note: this mode takes full control of the treemacs workspace list. +It is incompatible with `treemacs-project-follow-mode' (disabled +automatically). It also overrides +`treemacs-add-and-display-current-project-exclusively' (used by +Doom's `+treemacs/toggle') and suppresses `treemacs-persp' workspace +creation when active." :init-value nil :global t :group 'declarative-project :lighter " TDW" (if declarative-project-treemacs-mode (progn + ;; Layer 1: Pre-empt treemacs restore. Setting this flag before + ;; reading our cache prevents treemacs--maybe-load-workspaces from + ;; ever firing when our mode is active. + (put 'treemacs :state-is-restored t) + ;; treemacs-project-follow-mode replaces workspace content with the + ;; current project on an idle timer, which conflicts with our + ;; workspace ownership. Disable it when this mode is active. + (when (bound-and-true-p treemacs-project-follow-mode) + (treemacs-project-follow-mode -1) + (message "declarative-project-treemacs-mode: disabled treemacs-project-follow-mode (incompatible)")) + (advice-add 'treemacs-add-and-display-current-project-exclusively + :around #'declarative-project-treemacs--around-exclusive-display) + (when (fboundp 'treemacs-persp--ensure-workspace-exists) + (advice-add 'treemacs-persp--ensure-workspace-exists + :around #'declarative-project-treemacs--around-persp-ensure)) + ;; Layer 2: Safety-net advice in case treemacs--restore fires anyway. + (advice-add 'treemacs--restore :after + #'declarative-project-treemacs--after-treemacs-restore) + ;; Layer 3: Sync desired state into treemacs before its persist hook + ;; writes to disk on shutdown. Depth -90 ensures we run before + ;; treemacs--persist (depth 0). + (add-hook 'kill-emacs-hook + #'declarative-project-treemacs--sync-before-persist -90) (declarative-project-treemacs--read-cache) (when declarative-project-treemacs-autoprune (declarative-project-treemacs--prune-invalid-projects)) @@ -207,12 +427,27 @@ list and is still experimental." (add-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (add-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--override-workspaces)) + #'declarative-project-treemacs--on-workspace-switch) + (add-hook 'treemacs-select-functions + #'declarative-project-treemacs--on-select)) (declarative-project-treemacs--save-cache) + ;; Clear pre-emption flag so treemacs resumes normal behavior. + (put 'treemacs :state-is-restored nil) + (advice-remove 'treemacs-add-and-display-current-project-exclusively + #'declarative-project-treemacs--around-exclusive-display) + (when (fboundp 'treemacs-persp--ensure-workspace-exists) + (advice-remove 'treemacs-persp--ensure-workspace-exists + #'declarative-project-treemacs--around-persp-ensure)) + (advice-remove 'treemacs--restore + #'declarative-project-treemacs--after-treemacs-restore) + (remove-hook 'kill-emacs-hook + #'declarative-project-treemacs--sync-before-persist) (remove-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (remove-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--override-workspaces))) + #'declarative-project-treemacs--on-workspace-switch) + (remove-hook 'treemacs-select-functions + #'declarative-project-treemacs--on-select))) (defalias 'treemacs-declarative-workspaces-mode #'declarative-project-treemacs-mode) diff --git a/ob-declarative-project.el b/ob-declarative-project.el index 61ae9f6..e6ce3e7 100644 --- a/ob-declarative-project.el +++ b/ob-declarative-project.el @@ -2,16 +2,30 @@ ;; ;; Copyright (C) 2023 Hayden Stanko ;; -;; Author: Hayden Stanko -;; Maintainer: Hayden Stanko +;; Author: Hayden Stanko +;; Maintainer: Hayden Stanko ;; Created: March 15, 2026 -;; Version: 0.2.0 +;; Modified: March 21, 2026 +;; Version: 0.3.3 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode -;; Package-Requires: ((emacs "28.1") (org "9.0") (declarative-project-mode "0.2.0")) +;; Package-Requires: ((emacs "28.1") (org "9.0") (declarative-project-mode "0.3.3")) ;; ;; This file is not part of GNU Emacs. ;; +;; 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. +;; +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . +;; ;;; Commentary: ;; ;; Org-Babel support for declarative-project src blocks. @@ -30,6 +44,7 @@ (require 'ob) (require 'declarative-project-mode) +;;;###autoload (defun org-babel-execute:declarative-project (body params) "Execute a declarative-project source block. BODY is YAML/JSON content. The `:dir' header sets project root diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el new file mode 100644 index 0000000..5a79145 --- /dev/null +++ b/test/test-declarative-project-treemacs.el @@ -0,0 +1,880 @@ +;;; test-declarative-project-treemacs.el --- Tests for treemacs integration -*- lexical-binding: t -*- +;; +;; Copyright (C) 2023 Hayden Stanko +;; +;; 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. +;; +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . +;; +;;; Commentary: +;; Buttercup test suite for declarative-project-treemacs. + +;;; Code: + +(require 'test-helper) +(require 'declarative-project-treemacs) + +;;; ========================================================================== +;;; declarative-project-treemacs--minimal-desired-state +;;; ========================================================================== + +(describe "declarative-project-treemacs--minimal-desired-state" + (it "returns a list with one Default workspace" + (let ((state (declarative-project-treemacs--minimal-desired-state))) + (expect (length state) :to-equal 1) + (expect (treemacs-workspace->name (car state)) :to-equal "Default"))) + + (it "creates a workspace with empty projects list" + (let ((ws (car (declarative-project-treemacs--minimal-desired-state)))) + (expect (treemacs-workspace->projects ws) :to-equal nil)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--ensure-desired-state +;;; ========================================================================== + +(describe "declarative-project-treemacs--ensure-desired-state" + (it "initializes state when nil" + (with-treemacs-test-state + (declarative-project-treemacs--ensure-desired-state) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1))) + + (it "does not overwrite existing state" + (with-treemacs-test-state + (let ((custom-ws (treemacs-workspace->create! :name "Custom" :projects nil))) + (setq declarative-project-treemacs--desired-state (list custom-ws)) + (declarative-project-treemacs--ensure-desired-state) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Custom"))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--workspaces-by-name +;;; ========================================================================== + +(describe "declarative-project-treemacs--workspaces-by-name" + (it "finds a workspace by name" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Alpha" :projects nil) + (treemacs-workspace->create! :name "Beta" :projects nil))) + (let ((result (declarative-project-treemacs--workspaces-by-name "Beta"))) + (expect result :to-be-truthy) + (expect (treemacs-workspace->name result) :to-equal "Beta")))) + + (it "returns nil when no workspace matches" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Alpha" :projects nil))) + (expect (declarative-project-treemacs--workspaces-by-name "Nope") + :to-equal nil)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--save-cache / --read-cache +;;; ========================================================================== + +(describe "cache persistence" + (it "round-trips desired state through save and read" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "MyProj" :path "/tmp/myproj" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "TestWS" :projects (list proj)))) + (declarative-project-treemacs--save-cache) + ;; Clear and reload + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (let ((ws (car declarative-project-treemacs--desired-state))) + (expect (treemacs-workspace->name ws) :to-equal "TestWS") + (expect (length (treemacs-workspace->projects ws)) :to-equal 1))))) + + (it "initializes and saves when cache file does not exist" + (with-treemacs-test-state + (delete-file cache-file) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (file-exists-p cache-file) :to-be-truthy))) + + (it "normalizes old structs missing is-disabled? field after load" + (with-treemacs-test-state + ;; Write a cache file with old-layout structs (missing is-disabled?) + (with-temp-file cache-file + (insert ";; -*- no-byte-compile: t -*-\n") + (insert "(setq declarative-project-treemacs--desired-state '(") + ;; 2-field workspace: only name + projects, no is-disabled? + (insert "#s(treemacs-workspace \"OldWS\" (") + (insert "#s(treemacs-project \"OldProj\" \"/tmp/old\" local-readable nil)") + (insert "))") + (insert "))\n")) + (declarative-project-treemacs--read-cache) + (let ((ws (car declarative-project-treemacs--desired-state))) + (expect (treemacs-workspace->name ws) :to-equal "OldWS") + (expect (length (treemacs-workspace->projects ws)) :to-equal 1) + ;; The key check: is-disabled? accessor must not error + (expect (treemacs-workspace->is-disabled? ws) :not :to-throw))))) + +;;; ========================================================================== +;;; cache validation +;;; ========================================================================== + +(describe "cache validation" + (it "every entry after save+read is a proper workspace/project struct" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "P1" :path "/tmp/p1" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS1" :projects (list proj)))) + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (dolist (ws declarative-project-treemacs--desired-state) + (expect (treemacs-workspace-p ws) :to-be-truthy) + (expect (treemacs-workspace->name ws) :to-be-truthy) + (dolist (proj (treemacs-workspace->projects ws)) + (expect (treemacs-project-p proj) :to-be-truthy) + (expect (treemacs-project->name proj) :to-be-truthy) + (expect (treemacs-project->path proj) :to-be-truthy)))))) + + (it "no duplicate workspace names after repeated assign" + (with-treemacs-test-state + (declarative-project-treemacs--assign-project + (list :name "P1" :path "/tmp/p1" + :path-status 'local-readable :is-disabled? nil) + "TestWS") + (declarative-project-treemacs--assign-project + (list :name "P2" :path "/tmp/p2" + :path-status 'local-readable :is-disabled? nil) + "TestWS") + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (let ((names (mapcar #'treemacs-workspace->name + declarative-project-treemacs--desired-state))) + (expect (length names) :to-equal (length (delete-dups (copy-sequence names))))))) + + (it "preserves exact project counts across workspaces" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "Alpha" + :projects (list + (treemacs-project->create! :name "A1" :path "/tmp/a1" + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! :name "A2" :path "/tmp/a2" + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! :name "A3" :path "/tmp/a3" + :path-status 'local-readable :is-disabled? nil))) + (treemacs-workspace->create! + :name "Beta" + :projects (list + (treemacs-project->create! :name "B1" :path "/tmp/b1" + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! :name "B2" :path "/tmp/b2" + :path-status 'local-readable :is-disabled? nil))))) + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (let ((alpha (declarative-project-treemacs--workspaces-by-name "Alpha")) + (beta (declarative-project-treemacs--workspaces-by-name "Beta"))) + (expect (length (treemacs-workspace->projects alpha)) :to-equal 3) + (expect (length (treemacs-workspace->projects beta)) :to-equal 2)))) + + (it "unassign persists correctly through save+read" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "RemoveMe" :path "/tmp/rm" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS" :projects (list proj)))) + (declarative-project-treemacs--unassign-project "RemoveMe" "WS") + ;; save+read cycle + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (let ((ws (declarative-project-treemacs--workspaces-by-name "WS"))) + (expect (treemacs-workspace->projects ws) :to-equal nil))))) + + (it "recovers gracefully from corrupted cache file" + (with-treemacs-test-state + (with-temp-file cache-file + (insert "this is not valid elisp }{][")) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "recovers gracefully from empty cache file" + (with-treemacs-test-state + ;; Create a 0-byte file + (with-temp-file cache-file + (erase-buffer)) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "save output contains expected setq form" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Check" :projects nil))) + (declarative-project-treemacs--save-cache) + (let ((content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (expect content :to-match "setq declarative-project-treemacs--desired-state")))) + + (it "double round-trip produces identical cache file content" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "Stable" + :projects (list (treemacs-project->create! + :name "SP" :path "/tmp/sp" + :path-status 'local-readable :is-disabled? nil))))) + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (declarative-project-treemacs--save-cache) + (let ((first-content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (declarative-project-treemacs--save-cache) + (let ((second-content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (expect first-content :to-equal second-content)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--assign-project +;;; ========================================================================== + +(describe "declarative-project-treemacs--assign-project" + (it "creates a new workspace when target does not exist" + (with-treemacs-test-state + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "NewWS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "NewWS"))) + (expect ws :to-be-truthy) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1)))) + + (it "appends project to existing workspace" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "ExistingWS" :projects nil))) + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "ExistingWS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "ExistingWS"))) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1)))) + + (it "does not duplicate projects with the same name" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "WS" :projects nil))) + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "WS") + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "WS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "WS"))) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--unassign-project +;;; ========================================================================== + +(describe "declarative-project-treemacs--unassign-project" + (it "removes a project from a workspace" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS" :projects (list proj)))) + (declarative-project-treemacs--unassign-project "Proj" "WS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "WS"))) + (expect (treemacs-workspace->projects ws) :to-equal nil))))) + + (it "does not error when workspace does not exist (nil regression)" + (with-treemacs-test-state + (declarative-project-treemacs--ensure-desired-state) + (expect (declarative-project-treemacs--unassign-project + "Proj" "NonexistentWS") + :not :to-throw)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--remove-workspace +;;; ========================================================================== + +(describe "declarative-project-treemacs--remove-workspace" + (it "removes a workspace from desired state" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Keep" :projects nil) + (treemacs-workspace->create! :name "Remove" :projects nil))) + (declarative-project-treemacs--remove-workspace "Remove") + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Keep")))) + +;;; ========================================================================== +;;; declarative-project-treemacs--prune-invalid-projects +;;; ========================================================================== + +(describe "declarative-project-treemacs--prune-invalid-projects" + (it "removes projects whose paths do not exist" + (with-treemacs-test-state + (let ((valid-dir (make-temp-file "dpm-prune-" t))) + (unwind-protect + (progn + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS" + :projects + (list (treemacs-project->create! + :name "Valid" :path valid-dir + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! + :name "Invalid" :path "/nonexistent/path" + :path-status 'local-readable :is-disabled? nil))))) + (declarative-project-treemacs--prune-invalid-projects) + (let ((ws (car declarative-project-treemacs--desired-state))) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1) + (expect (treemacs-project->name + (car (treemacs-workspace->projects ws))) + :to-equal "Valid"))) + (delete-directory valid-dir t)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--override-workspaces +;;; ========================================================================== + +(describe "declarative-project-treemacs--override-workspaces" + (it "sets treemacs--workspaces to desired state" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "Override" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (declarative-project-treemacs--override-workspaces) + (expect treemacs--workspaces :to-equal state)))) + + (it "sets :state-is-restored flag to prevent treemacs persist-file restore" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "FlagTest" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (expect (get 'treemacs :state-is-restored) :not :to-be-truthy) + (declarative-project-treemacs--override-workspaces) + (expect (get 'treemacs :state-is-restored) :to-be t)))) + + (it "updates current workspace to matching object in desired state" + (with-treemacs-test-state + (let* ((old-ws (treemacs-workspace->create! :name "MyWS" :projects nil)) + (new-ws (treemacs-workspace->create! :name "MyWS" :projects nil))) + ;; Simulate treemacs having a stale workspace in scope shelf + (setf (treemacs-current-workspace) old-ws) + (setq declarative-project-treemacs--desired-state (list new-ws)) + (declarative-project-treemacs--override-workspaces) + ;; Current workspace should now be the new object, not the old one + (expect (treemacs-current-workspace) :to-be new-ws)))) + + (it "falls back to first desired workspace when current name is unknown" + (with-treemacs-test-state + (let* ((unknown-ws (treemacs-workspace->create! :name "Perspective main" :projects nil)) + (target-ws (treemacs-workspace->create! :name "MyProject" :projects nil))) + (setf (treemacs-current-workspace) unknown-ws) + (setq declarative-project-treemacs--desired-state (list target-ws)) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-current-workspace) :to-be target-ws)))) + + (it "falls back to first desired workspace when current workspace is nil" + (with-treemacs-test-state + (let ((target-ws (treemacs-workspace->create! :name "MyProject" :projects nil))) + (setf (treemacs-current-workspace) nil) + (setq declarative-project-treemacs--desired-state (list target-ws)) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-current-workspace) :to-be target-ws)))) + + (it "clears buffer project cache when switching workspace" + (with-treemacs-test-state + (let* ((old-ws (treemacs-workspace->create! :name "Old" :projects nil)) + (new-ws (treemacs-workspace->create! :name "New" :projects nil))) + (setf (treemacs-current-workspace) old-ws) + (setq declarative-project-treemacs--desired-state (list new-ws)) + ;; Set a buffer-local project cache value to verify it gets cleared + (setq-local treemacs--project-of-buffer 'stale-project) + (declarative-project-treemacs--override-workspaces) + ;; treemacs--invalidate-buffer-project-cache is a define-inline that + ;; clears treemacs--project-of-buffer in all buffers + (expect treemacs--project-of-buffer :to-be nil))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--assign-declared-project (hook integration) +;;; ========================================================================== + +(describe "declarative-project-treemacs--assign-declared-project" + (it "assigns project to declared workspaces when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t) + (resources (make-hash-table :test 'equal))) + (puthash 'treemacs-workspaces '("DeclaredWS") resources) + (puthash 'project-root "/tmp/declared/" resources) + (puthash 'project-name "DeclaredProj" resources) + (declarative-project-treemacs--assign-declared-project resources) + (let ((ws (declarative-project-treemacs--workspaces-by-name "DeclaredWS"))) + (expect ws :to-be-truthy) + (expect (treemacs-project->name + (car (treemacs-workspace->projects ws))) + :to-equal "DeclaredProj"))))) + + (it "does nothing when mode is not active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil) + (resources (make-hash-table :test 'equal))) + (puthash 'treemacs-workspaces '("WS") resources) + (puthash 'project-root "/tmp/proj/" resources) + (spy-on 'declarative-project-treemacs--assign-project) + (declarative-project-treemacs--assign-declared-project resources) + (expect 'declarative-project-treemacs--assign-project + :not :to-have-been-called))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--sync-workspace-list +;;; ========================================================================== + +(describe "declarative-project-treemacs--sync-workspace-list" + (it "sets treemacs--workspaces to desired state" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "SyncTest" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (declarative-project-treemacs--sync-workspace-list) + (expect treemacs--workspaces :to-equal state)))) + + (it "sets :state-is-restored flag" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "FlagTest" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (expect (get 'treemacs :state-is-restored) :not :to-be-truthy) + (declarative-project-treemacs--sync-workspace-list) + (expect (get 'treemacs :state-is-restored) :to-be t)))) + + (it "does not change the current workspace in scope shelf" + (with-treemacs-test-state + (let* ((persp-ws (treemacs-workspace->create! + :name "Perspective main" :projects nil)) + (declared-ws (treemacs-workspace->create! + :name "MyProject" :projects nil))) + (setf (treemacs-current-workspace) persp-ws) + (setq declarative-project-treemacs--desired-state (list declared-ws)) + (declarative-project-treemacs--sync-workspace-list) + ;; Current workspace should still be persp-ws, not overridden + (expect (treemacs-current-workspace) :to-be persp-ws))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--on-select +;;; ========================================================================== + +(describe "declarative-project-treemacs--on-select" + (it "calls sync-workspace-list regardless of reason argument" + (with-treemacs-test-state + (spy-on 'declarative-project-treemacs--sync-workspace-list) + (declarative-project-treemacs--on-select 'exists) + (declarative-project-treemacs--on-select 'none) + (expect 'declarative-project-treemacs--sync-workspace-list + :to-have-been-called-times 2)))) + +;;; ========================================================================== +;;; declarative-project-treemacs-mode (hook add/remove) +;;; ========================================================================== + +(describe "declarative-project-treemacs-mode" + (it "adds hooks when enabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (progn + (expect (memq #'declarative-project-treemacs--assign-declared-project + declarative-project--apply-treemacs-workspaces-hook) + :to-be-truthy) + (expect (memq #'declarative-project-treemacs--on-workspace-switch + treemacs-switch-workspace-hook) + :to-be-truthy) + (expect (memq #'declarative-project-treemacs--on-select + treemacs-select-functions) + :to-be-truthy)) + (declarative-project-treemacs-mode -1))))) + + (it "removes hooks when disabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (declarative-project-treemacs-mode -1) + (expect (memq #'declarative-project-treemacs--assign-declared-project + declarative-project--apply-treemacs-workspaces-hook) + :to-equal nil) + (expect (memq #'declarative-project-treemacs--on-workspace-switch + treemacs-switch-workspace-hook) + :to-equal nil) + (expect (memq #'declarative-project-treemacs--on-select + treemacs-select-functions) + :to-equal nil)))) + + (it "disables treemacs-project-follow-mode when enabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil) + (treemacs-project-follow-mode t)) + (spy-on 'treemacs-project-follow-mode) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect 'treemacs-project-follow-mode + :to-have-been-called-with -1) + (declarative-project-treemacs-mode -1)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--around-exclusive-display +;;; ========================================================================== + +(describe "declarative-project-treemacs--around-exclusive-display" + (it "calls treemacs-select-window when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'treemacs-select-window) + (let ((orig-called nil)) + (declarative-project-treemacs--around-exclusive-display + (lambda () (setq orig-called t))) + (expect 'treemacs-select-window :to-have-been-called) + (expect orig-called :to-equal nil))))) + + (it "calls orig-fn when mode is not active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'treemacs-select-window) + (let ((orig-called nil)) + (declarative-project-treemacs--around-exclusive-display + (lambda () (setq orig-called t))) + (expect 'treemacs-select-window :not :to-have-been-called) + (expect orig-called :to-equal t)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--around-persp-ensure +;;; ========================================================================== + +(describe "declarative-project-treemacs--around-persp-ensure" + (it "calls sync-workspace-list when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'declarative-project-treemacs--sync-workspace-list) + (let ((orig-called nil)) + (declarative-project-treemacs--around-persp-ensure + (lambda () (setq orig-called t))) + (expect 'declarative-project-treemacs--sync-workspace-list + :to-have-been-called) + (expect orig-called :to-equal nil))))) + + (it "calls orig-fn when mode is not active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'declarative-project-treemacs--sync-workspace-list) + (let ((orig-called nil)) + (declarative-project-treemacs--around-persp-ensure + (lambda () (setq orig-called t))) + (expect 'declarative-project-treemacs--sync-workspace-list + :not :to-have-been-called) + (expect orig-called :to-equal t)))))) + +;;; ========================================================================== +;;; Persistence defense (3-layer) +;;; ========================================================================== + +(describe "persistence defense: pre-emption flag (Layer 1)" + (it "sets :state-is-restored on enable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (put 'treemacs :state-is-restored nil) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (get 'treemacs :state-is-restored) :to-be t) + (declarative-project-treemacs-mode -1))))) + + (it "clears :state-is-restored on disable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (declarative-project-treemacs-mode -1) + (expect (get 'treemacs :state-is-restored) :to-be nil))))) + +(describe "persistence defense: after-restore advice (Layer 2)" + (it "re-applies desired state when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs--after-treemacs-restore) + (expect 'declarative-project-treemacs--override-workspaces + :to-have-been-called)))) + + (it "does nothing when mode is inactive" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs--after-treemacs-restore) + (expect 'declarative-project-treemacs--override-workspaces + :not :to-have-been-called)))) + + (it "is added as advice on enable and removed on disable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (advice-member-p + #'declarative-project-treemacs--after-treemacs-restore + 'treemacs--restore) + :to-be-truthy) + (declarative-project-treemacs-mode -1)) + (expect (advice-member-p + #'declarative-project-treemacs--after-treemacs-restore + 'treemacs--restore) + :to-equal nil))))) + +(describe "persistence defense: kill-emacs sync (Layer 3)" + (it "syncs desired state to treemacs--workspaces" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t) + (treemacs--workspaces nil)) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Synced" :projects nil))) + (declarative-project-treemacs--sync-before-persist) + (expect (treemacs-workspace->name (car treemacs--workspaces)) + :to-equal "Synced")))) + + (it "does not overwrite current-workspace-name from treemacs scope" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t) + (treemacs--workspaces nil)) + ;; User switched to "hobby splines" earlier — name is already set + (setq declarative-project-treemacs--current-workspace-name "hobby splines") + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "declarative project mode" :projects nil) + (treemacs-workspace->create! :name "hobby splines" :projects nil))) + ;; Simulate daemon: treemacs-current-workspace returns wrong ws + ;; (frame's scope shelf is gone, falls back to first/wrong workspace) + (setf (treemacs-current-workspace) + (car declarative-project-treemacs--desired-state)) + (declarative-project-treemacs--sync-before-persist) + ;; The persisted name must still be "hobby splines", not overwritten + (expect declarative-project-treemacs--current-workspace-name + :to-equal "hobby splines")))) + + (it "adds kill-emacs-hook on enable and removes on disable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (memq #'declarative-project-treemacs--sync-before-persist + kill-emacs-hook) + :to-be-truthy) + (declarative-project-treemacs-mode -1)) + (expect (memq #'declarative-project-treemacs--sync-before-persist + kill-emacs-hook) + :to-equal nil))))) + +;;; ========================================================================== +;;; declarative-project-treemacs-reset-cache +;;; ========================================================================== + +(describe "declarative-project-treemacs-reset-cache" + (it "resets desired state to single Default workspace" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Stale1" :projects nil) + (treemacs-workspace->create! :name "Stale2" :projects nil))) + (declarative-project-treemacs-reset-cache) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "saves cache file after reset" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Old" :projects nil))) + (declarative-project-treemacs-reset-cache) + ;; Re-read from disk and verify it was saved + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "calls --override-workspaces when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs-reset-cache) + (expect 'declarative-project-treemacs--override-workspaces + :to-have-been-called)))) + + (it "does not call --override-workspaces when mode is inactive" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs-reset-cache) + (expect 'declarative-project-treemacs--override-workspaces + :not :to-have-been-called))))) + +;;; ========================================================================== +;;; current workspace persistence +;;; ========================================================================== + +(describe "current workspace persistence" + (it "save-cache persists current workspace name to file" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Active" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setq declarative-project-treemacs--current-workspace-name "Active") + (declarative-project-treemacs--save-cache) + (let ((content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (expect content :to-match "current-workspace-name"))))) + + (it "round-trips current workspace name through save+read" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Persisted" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setq declarative-project-treemacs--current-workspace-name "Persisted") + (declarative-project-treemacs--save-cache) + ;; Clear and reload + (setq declarative-project-treemacs--current-workspace-name nil) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--current-workspace-name + :to-equal "Persisted")))) + + (it "override-workspaces restores persisted name instead of first" + (with-treemacs-test-state + (let* ((ws-a (treemacs-workspace->create! :name "Alpha" :projects nil)) + (ws-b (treemacs-workspace->create! :name "Beta" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws-a ws-b)) + (setq declarative-project-treemacs--current-workspace-name "Beta") + ;; Current workspace is nil (simulating fresh restart) + (setf (treemacs-current-workspace) nil) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-workspace->name (treemacs-current-workspace)) + :to-equal "Beta")))) + + (it "falls back to first when persisted name no longer exists" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "OnlyWS" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setq declarative-project-treemacs--current-workspace-name "Deleted") + (setf (treemacs-current-workspace) nil) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-workspace->name (treemacs-current-workspace)) + :to-equal "OnlyWS")))) + + (it "on-workspace-switch records current workspace name" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Switched" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setf (treemacs-current-workspace) ws) + (declarative-project-treemacs--on-workspace-switch) + (expect declarative-project-treemacs--current-workspace-name + :to-equal "Switched")))) + + (it "on-workspace-switch persists name to disk" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Persisted-Switch" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setf (treemacs-current-workspace) ws) + (declarative-project-treemacs--on-workspace-switch) + ;; Clear in-memory name and reload from disk + (setq declarative-project-treemacs--current-workspace-name nil) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--current-workspace-name + :to-equal "Persisted-Switch")))) + + (it "reset-cache clears current workspace name" + (with-treemacs-test-state + (setq declarative-project-treemacs--current-workspace-name "Stale") + (declarative-project-treemacs-reset-cache) + (expect declarative-project-treemacs--current-workspace-name :to-be nil)))) + +;;; ========================================================================== +;;; Advice registration (mode enable/disable) +;;; ========================================================================== + +(describe "declarative-project-treemacs-mode advice" + (it "registers exclusive-display advice when enabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (advice-member-p + #'declarative-project-treemacs--around-exclusive-display + 'treemacs-add-and-display-current-project-exclusively) + :to-be-truthy) + (declarative-project-treemacs-mode -1))))) + + (it "removes exclusive-display advice when disabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (declarative-project-treemacs-mode -1) + (expect (advice-member-p + #'declarative-project-treemacs--around-exclusive-display + 'treemacs-add-and-display-current-project-exclusively) + :to-equal nil))))) + +(provide 'test-declarative-project-treemacs) +;;; test-declarative-project-treemacs.el ends here diff --git a/test/test-declarative-project.el b/test/test-declarative-project.el index 9b5e87e..79146d8 100644 --- a/test/test-declarative-project.el +++ b/test/test-declarative-project.el @@ -1,5 +1,20 @@ ;;; test-declarative-project.el --- Tests for declarative-project-mode -*- lexical-binding: t -*- - +;; +;; Copyright (C) 2023 Hayden Stanko +;; +;; 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. +;; +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . +;; ;;; Commentary: ;; Buttercup test suite covering all known bugs and core functionality. @@ -211,7 +226,19 @@ (let ((resources (make-hash-table :test 'equal))) (spy-on 'run-hook-with-args) (declarative-project--apply-treemacs-workspaces resources) - (expect 'run-hook-with-args :not :to-have-been-called)))) + (expect 'run-hook-with-args :not :to-have-been-called))) + + (it "runs the hook when using workspaces alias key" + (with-temp-project-dir + (let ((resources (make-hash-table :test 'equal)) + (hook-called nil)) + (puthash 'workspaces '("WS1") resources) + (puthash 'project-file (expand-file-name ".project") resources) + (puthash 'name "Test" resources) + (let ((declarative-project--apply-treemacs-workspaces-hook + (list (lambda (res) (setq hook-called t))))) + (declarative-project--apply-treemacs-workspaces resources) + (expect hook-called :to-be-truthy)))))) ;;; ========================================================================== ;;; declarative-project--install-from-content @@ -254,7 +281,25 @@ (let ((result (declarative-project--install-from-content test-yaml-fixture project-dir (list (cons 'my-key "my-value"))))) - (expect (gethash 'my-key result) :to-equal "my-value"))))) + (expect (gethash 'my-key result) :to-equal "my-value")))) + + (it "uses root-directory from spec as project root when present" + (with-temp-project-dir + (let ((root-dir (make-temp-file "dpm-root-" t))) + (unwind-protect + (progn + (spy-on 'declarative-project--check-required-resources) + (spy-on 'declarative-project--install-project-dependencies) + (spy-on 'declarative-project--copy-local-files) + (spy-on 'declarative-project--create-symlinks) + (spy-on 'declarative-project--apply-treemacs-workspaces) + (let* ((yaml (format "name: Test\nroot-directory: %s\n" root-dir)) + (result (declarative-project--install-from-content + yaml project-dir))) + (expect (gethash 'project-root result) + :to-equal (file-name-as-directory + (expand-file-name root-dir))))) + (delete-directory root-dir t)))))) ;;; ========================================================================== ;;; declarative-project--install-project (parsing) @@ -329,7 +374,44 @@ (it "declarative-project-name returns project-name value" (let ((resources (make-hash-table :test 'equal))) (puthash 'project-name "My Project" resources) - (expect (declarative-project-name resources) :to-equal "My Project")))) + (expect (declarative-project-name resources) :to-equal "My Project"))) + + ;; --- Key alias tests --- + + (it "declarative-project-workspaces falls back to workspaces key" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'workspaces '("WS1") resources) + (expect (declarative-project-workspaces resources) :to-equal '("WS1")))) + + (it "declarative-project-workspaces prefers treemacs-workspaces over workspaces" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'workspaces '("old") resources) + (puthash 'treemacs-workspaces '("new") resources) + (expect (declarative-project-workspaces resources) :to-equal '("new")))) + + (it "declarative-project-name falls back to name key" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'name "Short Name" resources) + (expect (declarative-project-name resources) :to-equal "Short Name"))) + + (it "declarative-project-name prefers project-name over name" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'name "old" resources) + (puthash 'project-name "new" resources) + (expect (declarative-project-name resources) :to-equal "new"))) + + (it "declarative-project-root-directory falls back to root-directory key" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'root-directory "/tmp/myproject" resources) + (expect (declarative-project-root-directory resources) + :to-equal "/tmp/myproject/"))) + + (it "declarative-project-root-directory prefers project-root over root-directory" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'root-directory "/tmp/old" resources) + (puthash 'project-root "/tmp/new/" resources) + (expect (declarative-project-root-directory resources) + :to-equal "/tmp/new/")))) ;;; ========================================================================== ;;; defgroup / defcustom diff --git a/test/test-helper.el b/test/test-helper.el index 1d8feae..ce9614e 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -1,5 +1,20 @@ ;;; test-helper.el --- Test helpers for declarative-project-mode -*- lexical-binding: t -*- - +;; +;; Copyright (C) 2023 Hayden Stanko +;; +;; 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. +;; +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . +;; ;;; Commentary: ;; Fixtures, macros, and stubs for Buttercup tests. @@ -80,14 +95,82 @@ Binds `project-dir' and `project-file'." ;;; --- Treemacs stubs --- -;; Stub treemacs functions so tests don't require treemacs +;; Stub treemacs structs and functions so tests don't require treemacs. +;; Real treemacs uses cl-defstruct; we replicate just enough for the +;; treemacs extension module to work. + (unless (featurep 'treemacs) + ;; --- Structs --- + (cl-defstruct (treemacs-workspace (:constructor treemacs-workspace->create!)) + name projects is-disabled?) + + (cl-defstruct (treemacs-project (:constructor treemacs-project->create!)) + name path path-status is-disabled?) + + ;; --- Workspace API stubs --- + (defvar treemacs--workspaces nil + "Stub for treemacs workspace list.") + + (defvar treemacs-override-workspace nil + "Stub for treemacs workspace override variable.") + + (defvar treemacs--current-workspace-stub nil + "Stub storage for current workspace in tests.") + + (defun treemacs-current-workspace () + "Stub: return current workspace." + treemacs--current-workspace-stub) + + (gv-define-setter treemacs-current-workspace (val) + `(setq treemacs--current-workspace-stub ,val)) + (defun treemacs-do-create-workspace (&optional _name) '(success nil)) (defun treemacs-find-workspace-by-name (_name) nil) - (defmacro treemacs-with-workspace (_ws &rest body) - (declare (indent 1)) - `(progn ,@body)) - (defun treemacs-do-add-project-to-workspace (_path _name) nil)) + (defun treemacs-do-add-project-to-workspace (_path _name) nil) + (defvar treemacs-switch-workspace-hook nil + "Stub for treemacs workspace switch hook.") + + (defvar treemacs-select-functions nil + "Stub for treemacs select functions hook.") + + (defvar treemacs-project-follow-mode nil + "Stub for treemacs-project-follow-mode.") + + (defvar-local treemacs--project-of-buffer nil + "Stub for treemacs buffer-local project cache.") + + (defun treemacs-select-window (&optional _arg) + "Stub for treemacs-select-window." + nil) + + (defun treemacs-add-and-display-current-project-exclusively () + "Stub for treemacs-add-and-display-current-project-exclusively." + nil) + + (defun treemacs--restore () + "Stub for treemacs--restore (persistence)." + nil)) + +;;; --- Treemacs test macro --- + +(defmacro with-treemacs-test-state (&rest body) + "Execute BODY with a clean treemacs desired-state and temporary cache file. +Binds `cache-file' to the temporary cache path." + (declare (indent 0)) + `(let* ((cache-file (make-temp-file "dpm-treemacs-cache-" nil ".el")) + (declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--current-workspace-name nil) + (declarative-project-treemacs-cache-file cache-file) + (declarative-project-treemacs-mode nil) + (treemacs--current-workspace-stub nil) + (treemacs-select-functions nil) + (treemacs-project-follow-mode nil) + (kill-emacs-hook nil)) + (unwind-protect + (progn ,@body) + (put 'treemacs :state-is-restored nil) + (when (file-exists-p cache-file) + (delete-file cache-file))))) ;;; --- Helper functions ---