emacs: Managing projects

Working on project a key part of my workflow when using GNU/Emacs. Almost everything I work on can be part of a project. It might be simpler to give examples: tektoncd/pipeline checked out is a project, my ~/desktop/org is another project. There is only a handful of buffer in Emacs that I do not consider of any project, one example is the org-mode agenda.

In a project, I want to be able to:

Emacs 27.1 ships with a project library that has some useful function. But prior to this, the projectile project has been the way to go for managing projects in GNU/Emacs. projectile is also quite extensible and integrates relatively well with a bunch of other libraries.

The mnemonics key for the project is C-c p, and thus, any project command will start with that prefix.

Projectile

Let’s configure projectile using use-package.

(use-package projectile
  :commands
  (projectile-ack
   projectile-ag
   projectile-compile-project
   projectile-configure-project
   projectile-package-project
   projectile-install-project
   projectile-test-project
   projectile-run-project
   projectile-dired
   projectile-find-dir
   projectile-find-file
   projectile-find-file-dwim
   projectile-find-file-in-directory
   projectile-find-tag
   projectile-test-project
   projectile-grep
   projectile-invalidate-cache
   projectile-kill-buffers
   projectile-multi-occur
   projectile-project-p
   projectile-project-root
   projectile-recentf
   projectile-regenerate-tags
   projectile-replace
   projectile-replace-regexp
   projectile-run-async-shell-command-in-root
   projectile-run-shell-command-in-root
   projectile-switch-project
   projectile-switch-to-buffer
   projectile-vc
   projectile-commander)
  :bind-keymap ("C-c p" . projectile-command-map)
  :config
  <<projectile-completion>>
  <<projectile-variables>>
  <<projectile-compilation>>
  <<projectile-known-projects>>
  <<projectile-commander-methods>>
  <<projectile-custom-types>>
  (projectile-mode))

First thing first, let’s tell projectile to use the default completion system instead of ivy, or helm, or …

(setq-default projectile-completion-system 'default)

Let’s also configure some projectile behavior.

  • The default action when switch to a project should be the commander, as it allows to do different actions. This is done by setting projectile-command to project-switch-project-action.
  • When switching to tests (C-c p t), if there is no test files, create one. This is done by setting projectile-create-missing-test-files.
(setq-default projectile-switch-project-action #'projectile-commander
              projectile-create-missing-test-files t)

In order to make sure we can have a living compilation per project, we need to modify the buffer name to include the project name. This is easily do-able by writing a function for compilation-buffer-name-function.

(setq-default compilation-buffer-name-function (lambda (mode) (concat "*" (downcase mode) ": " (projectile-project-name) "*")))

Do not track known projects automatically, instead call projectile-add-known-project Remove dead projects when Emacs is idle

(setq-default projectile-track-known-projects-automatically nil)
(run-with-idle-timer 10 nil #'projectile-cleanup-known-projects)
(def-projectile-commander-method ?s
  "Open a *shell* buffer for the project"
  (projectile-run-eshell nil))
(def-projectile-commander-method ?c
  "Run `compile' in the project"
  (projectile-compile-project nil))

Custom project types

projectile allows to add custom project type, in addition to the built-in project types. See Projects - Projectile: The Project Interaction Library for Emacs for a little bit more detail. It should be possible to configure the configure, compile, package, install and test commands. One a the hope of this section is to be able to define highly customized project types so that doing C-p u on, let’s say, tektoncd/pipeline does the right thing by default.

An example of custom project type is the following.

;; Ruby + RSpec
(projectile-register-project-type 'ruby-rspec '("Gemfile" "lib" "spec")
                                  :project-file "Gemfile"
                                  :compile "bundle exec rake"
                                  :src-dir "lib/"
                                  :test "bundle exec rspec"
                                  :test-dir "spec/"
                                  :test-suffix "_spec")

One nice aspect of :compile (and some others) is that it can take a symbolic reference to a function, which means, you can define dynamic behavior. Based on the doc this works for :compile, :configure, :compilation-dir and :run (but my hope is it would work for :test and that a :package and an :install would exist).

TODO ko

First thing first, what makes a ko project. In most cases, a .ko.yaml will be present (at the root folder of the project). Let’s also define a function do detect if a it’s a ko project that uses the standard config folder for yamls.

(defun projectile-ko-project-p ()
  "Check if a project contains a .ko.yaml file."
  (projectile-verify-file ".ko.yaml"))
(defun projectile-ko-with-config-project-p ()
  "Check if a project is a ko project and has a config/ folder full of yaml"
  (and (projectile-ko-project-p)
       (projectile-verify-file-wildcard "config/*.yaml")))

Let’s register the ko project (with config). Long-term, the idea is to make different function for ko and ko-with-config projects.

(projectile-register-project-type 'ko-with-config #'projectile-ko-with-config-project-p
                                  :project-file ".ko.yaml" ; might not be required
                                  :configure 'projectile-ko-configure-command
                                  :compile 'projectile-ko-compile-command
                                  :test 'projectile-ko-test-command
                                  :run 'projectile-ko-run-command
                                  :package 'projectile-ko-package-command
                                  :install 'projectile-ko-install-command)

Let’s now dig a little bit more into the configure, compile, test, run, package and install commands. As we can pass it a function, we can define behaviour depending on the current opened buffer, etc. One assumption that we can make is that a ko project is also a go project.

configure

configure stands for ./configure scripts usually. Let’s see what it could be for ko project. Most likely related to file generations.

  • default to ./hack/update-codegen.sh if it is present.
(defun projectile-ko-configure-command ()
  "define a configure command for a ko project, depending on the opened file"
  (cond
   ((projectile-file-exists-p "hack/update-codegen.sh") "./hack/update-codegen.sh")))
compile

compile might be slightly different depending on the current major mode we are in, and maybe also depending on the folder.

  • default to go build -v ./...
  • go file (go-mode)
    • default to build the current package
    • if it is a test file, tests the current package
(defun projectile-ko-compile-command ()
  "define a compile command for a ko project, depending on the openend file "
  (cond
   ((eq major-mode 'go-mode) (projectile-ko-compile-command-go))
   ((eq major-mode 'yaml-mode) "yamllint .")
   (t "go build -v ./...")
   ))

(defun projectile-ko-compile-command-go ()
  "compile command for a ko project if in a go file"
  (let* ((current-file (buffer-file-name (current-buffer)))
         (relative-current-file (file-relative-name current-file (projectile-project-root)))
         (relative-current-folder (file-name-directory relative-current-file)))
    (message relative-current-file)
    (cond
     ((string-suffix-p "_test.go" relative-current-file) (format "go test -c -v ./%s" relative-current-folder))
     (t (format "go build -v ./%s" relative-current-folder)))))
test

test might be slightly different depending on the current major mode we are in, and might depend on the folder.

  • default to go test -v ./...
  • go file (go-mode)
    • default to run tests on the current package
    • if it is a test file, tests the current file (like go-test-current-file or gotest-ui-current-file)
(defun projectile-ko-test-command ()
  "define a test command for a ko project, depending on the openend file"
  (cond
   ((eq major-mode 'go-mode) (projectile-ko-test-command-go))
   (t "go test -v ./...")))

(defun projectile-ko-test-command-go ()
  "test command for a ko project if in a go file"
  (let* ((current-file (buffer-file-name (current-buffer)))
         (relative-current-file (file-relative-name current-file (projectile-project-root)))
         (relative-current-folder (file-name-directory relative-current-file)))
    (cond
     ((string-suffix-p "_test.go" relative-current-file) (projectile-ko-command-go-test relative-current-file))
     (t (format "go test -v ./%s" relative-current-folder)))))

(defun projectile-ko-command-go-test (current-file)
  "get the command for a go test"
  (cond
   ((gotest-module-available-p) (projectile-ko-command-go-test-gotest current-file))
   (t (format "go test -v ./%s" current-file))))

(defun projectile-ko-command-go-test-gotest (current-file)
  "get the command for a go test with gotest module enabled"
  (message default-directory)
  (let ((data (go-test--get-current-file-testing-data)))
    (format "go test -run='%s' -v ./%s" data (file-name-directory current-file))))

(defun gotest-module-available-p ()
  "is go-test module available"
  (fboundp 'go-test--get-current-file-data))
run

run is usually about running the project binary or something.

(defun projectile-ko-run-command ()
  "define a run command for a ko project, depending on the openend file "
  (cond
   ((eq major-mode 'go-mode) (projectile-ko-run-command-go))
   ;; nothing by default ?
   ))

(defun projectile-ko-run-command-go ()
  "test command for a ko project if in a go file"
  (let* ((current-file (buffer-file-name (current-buffer)))
         (relative-current-file (file-relative-name current-file (projectile-project-root)))
         (relative-current-folder (file-name-directory relative-current-file)))
    (cond
     ((string-prefix-p "cmd/" relative-current-file) (format "go run ./%s" relative-current-folder)))))
package

package is usually about generating a package, for a maven project this would be mvn package, for a project with a Dockerfile, this could be build the image(s). For a ko project this is about building and pushing the images that are going to be deployed. This is achieved by doing a ko resolve.

(defun projectile-ko-package-command ()
  "define a package command for a ko project, depending on the openend file "
  (cond
   ((eq major-mode 'go-mode) (projectile-ko-package-command-go))
   (t "ko resolve --push=false --oci-layout-path=/tmp/oci -f config")
   ))

(defun projectile-ko-package-command-go ()
  "package command for a ko project if in a go file"
  (let* ((current-file (buffer-file-name (current-buffer)))
         (relative-current-file (file-relative-name current-file (projectile-project-root)))
         (relative-current-folder (file-name-directory relative-current-file)))
    (cond
     ((string-prefix-p "cmd/" relative-current-file) (format "ko publish --push=false ./%s" relative-current-folder)))))
install

install is about installing the project artifact somewhere (usually make install)

(defun projectile-ko-install-command ()
  "define a install command for a ko project, depending on the openend file "
  "ko apply -f config/")

TODO Others

  • Detect project type
    • .ko.yaml => run is ko apply -f …
    • is there a Makefile ?
    • tkn and tekton file
    • home detection: systems, users, ci.nix, shell.nix, hosts.nix, systems.nix
      • if in pkgs, run nix-build pkgs -A …, and try to detect the file derivations
      • if in tools/emacs (elisp), tangle files from ~/desktop/org/notes
      • detect hostname and act based on it:
        • naruhodo: make home-switch, …
        • wakasu: make switch, …
        • Could also detect using nixos-version
  • Hook projectile run/compile/test to multi-compile Group things together, so that I can either choose from a list of different compile options or run my command

asok/projectile-rails: Emacs Rails mode based on projectile is also quite interesting.

Configuration layout

Here we define the config-projects.el file that gets generated by the source blocks in our Org document. This is the file that actually gets loaded on startup. The placeholders in angled brackets correspond to the NAME directives above the SRC blocks throughout this document.

;;; config-projects.el --- -*- lexical-binding: t; -*-
;;; Commentary:
;;; Project related configuration.
;;; This is mainly using projectile now, but built-in projects module seems promising for long-term.
;;; Note: this file is autogenerated from an org-mode file.
;;; Code:

<<projectile>>

(provide 'config-projects)
;;; config-projects.el ends here