diff --git a/CHANGELOG.md b/CHANGELOG.md index 851f13971..cf6b70264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### New features +- [#2199](https://github.com/bbatsov/projectile/pull/2199): Add `projectile-files-transform-functions`, a list of functions applied to every file name a project index produces, so names collected as raw byte escapes (non-ASCII characters under a legacy `file-name-coding-system`, e.g. Cyrillic or Turkish on Windows) can be decoded before they are cached and presented. - [#2182](https://github.com/bbatsov/projectile/pull/2182): Add `projectile-todos-in-projects` and `projectile-multi-occur-in-projects`, so every sibling command now has a public generic taking any list of projects behind it and a command for a group of your own is a two-line wrapper for all five. - [#2181](https://github.com/bbatsov/projectile/pull/2181): Sapling and Bazaar projects can now list their own ignored files, via the new `projectile-sapling-ignored-command` and `projectile-bzr-ignored-command`. - [#2180](https://github.com/bbatsov/projectile/pull/2180): Every shell backend can now open in another window (`s-p x 4 `), not just vterm, eat and ghostel: `shell`, `eshell`, `ielm`, `term` and the backend-dispatching `projectile-run` gained `-other-window` commands. diff --git a/doc/modules/ROOT/pages/indexing.adoc b/doc/modules/ROOT/pages/indexing.adoc index e2a337633..5c6eb2cd0 100644 --- a/doc/modules/ROOT/pages/indexing.adoc +++ b/doc/modules/ROOT/pages/indexing.adoc @@ -302,6 +302,36 @@ TIP: For the details of each ignore mechanism (`.projectile`, the indexing tools, and the global ignore/unignore variables), see xref:ignoring.adoc[Ignoring files]. +== Transforming file names + +On some platforms file names containing non-ASCII characters (for +example Cyrillic or Turkish) are collected as raw byte escapes like +`\305\264\303\251`, when the file-name coding system doesn't cover +them - a common case is Emacs on Windows with a legacy system code +page. Both the file list and the persistent cache then show the +escapes instead of the actual names. + +The `projectile-files-transform-functions` option applies a list of +functions to every file name Projectile collects for a project. The +functions run when the project is (re)indexed - before the list is +cached or presented - so commands such as `projectile-find-file` and +the persistent cache hold the transformed, readable names: + +[source,elisp] +---- +;; Decode raw-byte file names as UTF-8. +(setq projectile-files-transform-functions + (list (lambda (file) (decode-coding-string file 'utf-8)))) +---- + +The default value is nil: file names are used as collected and no +extra work is done. + +NOTE: The transforms run only during (re)indexing. Already cached +file lists keep their previous representation, so after changing the +option clear the cache with `M-x projectile-invalidate-cache` +(`s-p i`). + == Sorting You can choose how Projectile sorts files by customizing `projectile-sort-order`. diff --git a/projectile.el b/projectile.el index 11abeb772..a6667e0d1 100644 --- a/projectile.el +++ b/projectile.el @@ -318,6 +318,33 @@ A value of nil means the cache never expires." (natnum :tag "Seconds")) :package-version '(projectile . "1.0.0")) +(defcustom projectile-files-transform-functions nil + "Functions applied to each file name produced by project indexing. + +Each function is called with a file name and must return a file +name. The functions are applied, in the order they appear in the +list, to every file name Projectile collects for the current +project - before the list is cached and presented in commands such +as `projectile-find-file'. + +This is useful on platforms where file names containing non-ASCII +characters (e.g. Cyrillic or Turkish) are collected as raw byte +escapes like \"\\305\\264\\303\\251\", because the file-name coding +system doesn't cover them - for instance Emacs on Windows with a +legacy system code page. Decoding on collection keeps both the +file list and the persistent cache readable: + + (setq projectile-files-transform-functions + (list (lambda (file) (decode-coding-string file 'utf-8)))) + +The default value is nil - file names are used as collected and no +extra work is done. The functions run only when a project is +(re)indexed, so after changing this option clear the cache with +`projectile-invalidate-cache' to re-index with the new transforms." + :group 'projectile + :type 'hook + :package-version '(projectile . "3.5.0")) + (define-obsolete-variable-alias 'projectile-auto-discover 'projectile-auto-discover-projects "3.4.0") @@ -5115,6 +5142,19 @@ CALLER is accepted for backward compatibility but no longer used." ;;; Listing a project's files and directories +(defun projectile--transform-file-names (files) + "Apply `projectile-files-transform-functions' to every name in FILES. +Return FILES unchanged when no transform is configured." + (if (null projectile-files-transform-functions) + files + (mapcar + (lambda (file) + (let ((result file)) + (dolist (fun projectile-files-transform-functions) + (setq result (funcall fun result))) + result)) + files))) + (defun projectile-project-files (project-root) "Return a list of files for the PROJECT-ROOT." (let (files) @@ -5194,6 +5234,10 @@ CALLER is accepted for backward compatibility but no longer used." files)))) dirs)))))) + ;; Apply the user-configured transforms to every collected name, + ;; so both the cache and the callers see the final representation. + (setq files (projectile--transform-file-names files)) + ;; Save the cached list. (when projectile-enable-caching (projectile-cache-project project-root files) diff --git a/test/projectile-indexing-test.el b/test/projectile-indexing-test.el index 3c9159102..fce3756a9 100644 --- a/test/projectile-indexing-test.el +++ b/test/projectile-indexing-test.el @@ -1377,4 +1377,30 @@ (projectile-alien-honors-ignores nil)) (expect (projectile-project-files root) :to-contain "docs/a.text"))))))) +(describe "projectile-files-transform-functions" + (it "passes file names through when no transform is configured" + (let (projectile-files-transform-functions) + (expect (projectile--transform-file-names '("a.txt" "b/c.txt")) + :to-equal '("a.txt" "b/c.txt")))) + (it "applies the configured functions in order" + (let ((projectile-files-transform-functions + (list (lambda (file) (concat "x/" file)) + #'upcase))) + (expect (projectile--transform-file-names '("a.txt" "b/c.txt")) + :to-equal '("X/A.TXT" "X/B/C.TXT")))) + (it "transforms collected file names before they are cached" + (spy-on 'projectile-project-vcs :and-return-value 'git) + (spy-on 'projectile-get-project-directories :and-return-value '("/my/root/")) + (spy-on 'projectile--dir-files-alien-maybe-async :and-return-value '("a.txt" "b.txt")) + (spy-on 'projectile--alien-apply-ignores :and-call-fake + (lambda (_root _vcs files) files)) + (spy-on 'projectile-cache-project) + (let ((projectile-indexing-method 'alien) + (projectile-enable-caching t) + (projectile-files-transform-functions (list #'upcase))) + (expect (projectile-project-files "/my/root/") :to-equal '("A.TXT" "B.TXT")) + (expect 'projectile-cache-project :to-have-been-called-times 1) + (expect 'projectile-cache-project + :to-have-been-called-with "/my/root/" '("A.TXT" "B.TXT"))))) + ;;; projectile-indexing-test.el ends here