;;; javaimp.el --- Add and reorder Java import statements in Maven projects -*- lexical-binding: t; -*- ;; Copyright (C) 2014, 2015, 2016 Free Software Foundation, Inc. ;; Author: Filipp Gunbin ;; Maintainer: Filipp Gunbin ;; Version: 0.6 ;; Keywords: java, maven, programming ;;; Commentary: ;; Allows to manage Java import statements in Maven projects. ;; ;; Quick start: ;; ;; - customize `javaimp-import-group-alist' ;; - call `javaimp-maven-visit-project', giving it the top-level project ;; directory where pom.xml resides ;; ;; Then in a Java buffer visiting a file under that project or one of its ;; submodules call `javaimp-organize-imports' or `javaimp-add-import'. ;; ;; This module does not add all needed imports automatically! It only helps ;; you to quickly add imports when stepping through compilation errors. ;; ;; Some details: ;; ;; If Maven failed, you can see its output in the buffer named by ;; `javaimp-debug-buf-name' (default is "*javaimp-debug*"). ;; ;; Contents of jar files and Maven project structures (pom.xml) are cached, ;; so usually only first command should take a considerable amount of time ;; to complete. If a modules's pom.xml or any of its parents' pom.xml was ;; changed (i.e. any of them was modified after information was loaded), ;; `mvn dependency:build-classpath' is re-run on the current module. If a ;; jar file was changed, its contents are re-read. ;; ;; If you make some changes which change project hierarchy, you should ;; re-visit the parent again with `javaimp-maven-visit-project'. ;; ;; Currently inner classes are filtered out from completion alternatives. ;; You can always import top-level class and use qualified name. ;; ;; ;; Example of initialization: ;; ;; (require 'javaimp) ;; ;; (add-to-list 'javaimp-import-group-alist ;; '("\\`\\(my\\.company\\.\\|my\\.company2\\.\\)" . 80)) ;; ;; (setq javaimp-additional-source-dirs '("generated-sources/thrift")) ;; ;; (add-hook 'java-mode-hook ;; (lambda () ;; (local-set-key "\C-ci" 'javaimp-add-import) ;; (local-set-key "\C-co" 'javaimp-organize-imports))) ;; ;; ;; TODO: ;; ;; - use functions `cygwin-convert-file-name-from-windows' and ;; `cygwin-convert-file-name-to-windows' when they are available instead of ;; calling `cygpath'. See https://cygwin.com/ml/cygwin/2013-03/msg00228.html. ;; - save/restore state ;; - `javaimp-add-import': without prefix arg narrow alternatives by local name; ;; with prefix arg include all classes in alternatives ;; - types for defcustom ;;; Code: (require 'cl-lib) (require 'seq) (require 'xml) ;; User options (defgroup javaimp () "Add and reorder Java import statements in Maven projects") (defcustom javaimp-import-group-alist '(("\\`javax?\\." . 10)) "Specifies how to group classes and how to order resulting groups in the imports list. Each element should be of the form `(CLASSNAME-REGEXP . ORDER)' where `CLASSNAME-REGEXP' is a regexp matching the fully qualified class name. Lowest-order groups are placed earlier. The order of classes which were not matched is defined by `javaimp-import-default-order'.") (defcustom javaimp-import-default-order 50 "Defines the order of classes which were not matched by `javaimp-import-group-alist'") (defcustom javaimp-jdk-home (getenv "JAVA_HOME") "Path to the JDK. It is used to find JDK jars to scan. By default, it is set from the JAVA_HOME environment variable.") (defcustom javaimp-additional-source-dirs nil "List of directories where additional (e.g. generated) source files reside. Each directory is a relative path from ${project.build.directory} project property value. Typically you would check documentation for a Maven plugin, look at the parameter's default value there and add it to this list. E.g. \"${project.build.directory}/generated-sources/\" becomes \"generated-sources/\" (note the absence of the leading slash. Custom values set in plugin configuration in pom.xml are not supported yet.") (defcustom javaimp-mvn-program "mvn" "Path to the `mvn' program. Customize it if the program is not on `exec-path'.") (defcustom javaimp-cygpath-program (if (eq system-type 'cygwin) "cygpath") "Path to the `cygpath' program (Cygwin only). Customize it if the program is not on `exec-path'.") (defcustom javaimp-jar-program "jar" "Path to the `jar' program used to read contents of jar files. Customize it if the program is not on `exec-path'.") (defcustom javaimp-include-current-module-classes t "If non-nil, current module's classes are included into completion alternatives. `javaimp-add-import' will find all java files in the current project and add their fully-qualified names to the completion alternatives list.") ;; Variables and constants (defvar javaimp-project-forest nil "Visited projects.") (defvar javaimp-cached-jars nil "Alist of cached jars. Each element is of the form (FILE . CACHED-JAR).") (defconst javaimp-debug-buf-name "*javaimp-debug*") ;; Structs (cl-defstruct javaimp-node parent children contents) (cl-defstruct javaimp-module id parent-id file final-name packaging source-dir test-source-dir build-dir modules dep-jars load-ts) (cl-defstruct javaimp-id group artifact version) (cl-defstruct javaimp-cached-jar file read-ts classes) ;; Utilities (defun javaimp--xml-children (xml-tree child-name) "Returns list of children of XML-TREE filtered by CHILD-NAME" (seq-filter (lambda (child) (and (consp child) (eq (car child) child-name))) (cddr xml-tree))) (defun javaimp--xml-child (name el) "Returns a child of EL named by symbol NAME" (assq name (cddr el))) (defun javaimp--xml-first-child (el) "Returns a first child of EL" (car (cddr el))) (defun javaimp--get-file-ts (file) (nth 5 (file-attributes file))) (defun javaimp--get-jdk-jars () (if javaimp-jdk-home (let ((jre-lib-dir (concat (file-name-as-directory javaimp-jdk-home) (file-name-as-directory "jre") (file-name-as-directory "lib")))) (directory-files jre-lib-dir t "\\.jar\\'")))) (defun javaimp-cygpath-convert-maybe (path &optional mode is-really-path) "On Cygwin, converts PATH using cygpath according to MODE and IS-REALLY-PATH. If MODE is `unix' (the default), adds -u switch. If MODE is `windows', adds -m switch. If `is-really-path' is non-nil, adds `-p' switch. On other systems, PATH is returned unchanged." (if (eq system-type 'cygwin) (progn (unless mode (setq mode 'unix)) (let (args) (push (cond ((eq mode 'unix) "-u") ((eq mode 'windows) "-m") (t (error "Invalid mode: %s" mode))) args) (and is-really-path (push "-p" args)) (push path args) (car (apply #'process-lines javaimp-cygpath-program args)))) path)) ;; Project loading ;;;###autoload (defun javaimp-maven-visit-project (path) "Loads a project and its submodules. PATH should point to a directory containing pom.xml. Calls `mvn help:effective-pom' on the pom.xml in the PATH, reads project structure from the output and records which files belong to which modules and other module information. After being processed by this command, the module tree becomes known to javaimp and `javaimp-add-import' maybe called inside any module file." (interactive "DVisit maven project: ") (let ((file (expand-file-name (concat (file-name-as-directory path) "pom.xml")))) (unless (file-readable-p file) (error "Cannot read file: %s" file)) ;; delete previous loaded tree, if any (setq javaimp-project-forest (seq-remove (lambda (tree) (equal (javaimp-module-file (javaimp-node-contents tree)) file)) javaimp-project-forest)) (let ((tree (javaimp--maven-xml-load-tree file))) (if tree (push tree javaimp-project-forest))) (message "Loaded tree for %s" file))) ;; Maven XML routines (defun javaimp--maven-xml-load-tree (file) "Invokes `mvn help:effective-pom' on FILE and using its output creates a tree of Maven projects starting from FILE. Children which link to the parent via the element are inheriting children and are also included. Subordinate modules with no inheritance are not included." (let ((xml-tree (javaimp--maven-xml-read-effective-pom file))) (cond ((assq 'project xml-tree) (let ((project-elt (assq 'project xml-tree)) (submodules (javaimp--xml-children (javaimp--xml-child 'modules project-elt) 'module))) (and submodules ;; no real children (message "Independent submodules: %s" (mapconcat #'javaimp--xml-first-child submodules ", "))) (let ((module (javaimp--maven-xml-parse-module project-elt))) (javaimp--maven-build-tree (javaimp-module-id module) nil (list module) file)))) ((assq 'projects xml-tree) ;; we have are inheriting children - they and their children, if ;; any, are listed in a linear list (let* ((project-elts (javaimp--xml-children (assq 'projects xml-tree) 'project)) (all-modules (mapcar #'javaimp--maven-xml-parse-module project-elts))) (message "Total modules: %d" (length all-modules)) (javaimp--maven-build-tree (javaimp-module-id (car all-modules)) nil all-modules file))) (t ;; neither nor - error (error "Invalid `help:effective-pom' output"))))) (defun javaimp--maven-xml-read-effective-pom (pom) "Calls `mvn help:effective:pom and returns XML parse tree" (message "Loading root pom %s..." pom) (javaimp--maven-call pom "help:effective-pom" (lambda () (let ((xml-start-pos (save-excursion (progn (goto-char (point-min)) (re-search-forward "<\\?xml\\|")) (match-end 0))))) (xml-parse-region xml-start-pos xml-end-pos))))) (defun javaimp--maven-xml-parse-module (project-elt) (let ((build-elt (javaimp--xml-child 'build project-elt))) (make-javaimp-module :id (javaimp--maven-xml-extract-id project-elt) :parent-id (javaimp--maven-xml-extract-id (javaimp--xml-child 'parent project-elt)) ;; we set `file' slot later because raw element does not contain ;; pom file path, so we need to construct it during tree construction :file nil :final-name (javaimp--xml-first-child (javaimp--xml-child 'finalName build-elt)) :packaging (javaimp--xml-first-child (javaimp--xml-child 'packaging project-elt)) :source-dir (file-name-as-directory (javaimp-cygpath-convert-maybe (javaimp--xml-first-child (javaimp--xml-child 'sourceDirectory build-elt)))) :test-source-dir (file-name-as-directory (javaimp-cygpath-convert-maybe (javaimp--xml-first-child (javaimp--xml-child 'testSourceDirectory build-elt)))) :build-dir (file-name-as-directory (javaimp-cygpath-convert-maybe (javaimp--xml-first-child (javaimp--xml-child 'directory build-elt)))) :modules (mapcar (lambda (module-elt) (javaimp--xml-first-child module-elt)) (javaimp--xml-children (javaimp--xml-child 'modules project-elt) 'module)) :dep-jars nil ; dep-jars is initialized lazily on demand :load-ts (current-time)))) (defun javaimp--maven-xml-extract-id (elt) (make-javaimp-id :group (javaimp--xml-first-child (javaimp--xml-child 'groupId elt)) :artifact (javaimp--xml-first-child (javaimp--xml-child 'artifactId elt)) :version (javaimp--xml-first-child (javaimp--xml-child 'version elt)))) (defun javaimp--maven-xml-file-matches (file id parent-id) (let* ((xml-tree (with-temp-buffer (insert-file-contents file) (xml-parse-region (point-min) (point-max)))) (project-elt (assq 'project xml-tree)) (tested-id (javaimp--maven-xml-extract-id project-elt)) (tested-parent-id (javaimp--maven-xml-extract-id (assq 'parent project-elt)))) ;; seems that the only mandatory component in tested ids is artifact, while ;; group and version may be inherited and thus not presented in pom.xml (let ((test (if (or (null (javaimp-id-group tested-id)) (null (javaimp-id-version tested-id)) (null (javaimp-id-group tested-parent-id)) (null (javaimp-id-version tested-parent-id))) (progn (message "File %s contains incomplete id, using lax match" file) (lambda (first second) (equal (javaimp-id-artifact first) (javaimp-id-artifact second)))) #'equal))) (and (funcall test tested-id id) (funcall test tested-parent-id parent-id))))) ;; Maven routines (defun javaimp--maven-call (pom-file target handler) "Runs Maven target TARGET on POM-FILE, then calls HANDLER in the temporary buffer and returns its result" (message "Calling \"mvn %s\" on pom: %s" target pom-file) (with-temp-buffer (let* ((pom-file (javaimp-cygpath-convert-maybe pom-file)) (status ;; TODO check in Maven output on Gnu/Linux (let ((coding-system-for-read (if (eq system-type 'cygwin) 'utf-8-dos))) (process-file javaimp-mvn-program nil t nil "-f" pom-file target))) (buf (current-buffer))) (with-current-buffer (get-buffer-create javaimp-debug-buf-name) (erase-buffer) (insert-buffer-substring buf)) (or (and (numberp status) (= status 0)) (error "Maven target \"%s\" failed with status \"%s\"" target status)) (goto-char (point-min)) (funcall handler)))) (defun javaimp--maven-build-tree (id parent-node all-modules file) (message "Building tree for project: %s" id) (let ((this (or (seq-find (lambda (m) (equal (javaimp-module-id m) id)) all-modules) (error "Cannot find module %s!" id))) ;; although each real parent has section, more reliable ;; way to build hirarchy is to analyze node in each child (children (seq-filter (lambda (m) (equal (javaimp-module-parent-id m) id)) all-modules))) (if (and (null children) (string= (javaimp-module-packaging this) "pom")) (progn (message "Skipping empty aggregate module %s" (javaimp-module-id this)) nil) ;; here we can finally set the `file' slot as the path is known at ;; this time (setf (javaimp-module-file this) file) ;; make node (let ((this-node (make-javaimp-node :parent parent-node :children nil :contents this))) (setf (javaimp-node-children this-node) (mapcar (lambda (child) (let ((child-file (javaimp--maven-get-submodule-file child file (javaimp-module-modules this)))) (javaimp--maven-build-tree (javaimp-module-id child) this-node all-modules child-file))) children)) this-node)))) (defun javaimp--maven-get-submodule-file (submodule parent-file rel-paths-from-parent) ;; seems that the only reliable way to match a module parsed from ;; element with module relative path taken from is to ;; visit pom and check that id and parent-id matches (let* ((parent-dir (file-name-directory parent-file)) (files (mapcar (lambda (rel-path) (concat parent-dir (file-name-as-directory rel-path) "pom.xml")) rel-paths-from-parent))) (or (seq-find (lambda (file) (javaimp--maven-xml-file-matches file (javaimp-module-id submodule) (javaimp-module-parent-id submodule))) files) (error "Cannot find file for module: %s" (javaimp-module-id submodule))))) ;;; Loading dep-jars (defun javaimp--maven-update-module-maybe (node) (let (need-update) ;; are deps not initialized? (let ((module (javaimp-node-contents node))) (if (null (javaimp-module-dep-jars module)) (setq need-update t))) ;; were any pom.xml files updated after last load? (let ((tmp node)) (while (and tmp (not need-update)) (let ((module (javaimp-node-contents tmp))) (if (> (float-time (javaimp--get-file-ts (javaimp-module-file module))) (float-time (javaimp-module-load-ts module))) (setq need-update t))) (setq tmp (javaimp-node-parent tmp)))) (when need-update ;; update current module (let ((module (javaimp-node-contents node))) ;; reload & update dep-jars (setf (javaimp-module-dep-jars module) (javaimp--maven-fetch-dep-jars module)) ;; update load-ts (setf (javaimp-module-load-ts module) (current-time)))))) (defun javaimp--maven-fetch-dep-jars (module) (let ((raw-line (javaimp--maven-call (javaimp-module-file module) "dependency:build-classpath" (lambda () (goto-char (point-min)) (search-forward "Dependencies classpath:") (forward-line 1) (thing-at-point 'line)))) (separator-regex (concat "[" path-separator "\n" "]+"))) (split-string (javaimp-cygpath-convert-maybe raw-line 'unix t) separator-regex t))) ;; Working with jar classes (defun javaimp--get-jar-classes (file) (let ((cached (cdr (assoc file javaimp-cached-jars)))) (cond ((null cached) ;; create, load & put into cache (setq cached (make-javaimp-cached-jar :file file :read-ts (javaimp--get-file-ts file) :classes (javaimp--fetch-jar-classes file))) (push (cons file cached) javaimp-cached-jars)) ((> (float-time (javaimp--get-file-ts (javaimp-cached-jar-file cached))) (float-time (javaimp-cached-jar-read-ts cached))) ;; reload (setf (javaimp-cached-jar-classes cached) (javaimp--fetch-jar-classes file)) ;; update read-ts (setf (javaimp-cached-jar-read-ts cached) (current-time)))) ;; return from cached (javaimp-cached-jar-classes cached))) (defun javaimp--fetch-jar-classes (file) (message "Reading classes in file: %s" file) (with-temp-buffer (let ((coding-system-for-read (and (eq system-type 'cygwin) 'utf-8-dos))) ;; on cygwin, "jar" is a windows program, so file path needs to be ;; converted appropriately. (process-file javaimp-jar-program nil t nil ;; `jar' accepts commands/options as a single string "tf" (javaimp-cygpath-convert-maybe file 'windows)) (goto-char (point-min)) (while (search-forward "/" nil t) (replace-match ".")) (goto-char (point-min)) (let (result) (while (re-search-forward "\\(^[[:alnum:]._]+\\)\\.class$" nil t) (push (match-string 1) result)) result)))) ;; Tree search routines (defun javaimp--find-node (predicate) (javaimp--find-node-in-forest javaimp-project-forest predicate)) (defun javaimp--select-nodes (predicate) (javaimp--select-nodes-from-forest javaimp-project-forest predicate)) (defun javaimp--find-node-in-forest (forest predicate) (catch 'found (dolist (tree forest) (javaimp--find-node-in-tree tree predicate)))) (defun javaimp--find-node-in-tree (tree predicate) (if tree (progn (if (funcall predicate (javaimp-node-contents tree)) (throw 'found tree)) (dolist (child (javaimp-node-children tree)) (javaimp--find-node-in-tree child predicate))))) (defun javaimp--select-nodes-from-forest (forest predicate) (apply #'seq-concatenate 'list (mapcar (lambda (tree) (javaimp--select-nodes-from-tree tree predicate)) forest))) (defun javaimp--select-nodes-from-tree (tree predicate) (if tree (append (if (funcall predicate (javaimp-node-contents tree)) (list tree)) (apply #'seq-concatenate 'list (mapcar (lambda (child) (javaimp--select-nodes-from-tree child predicate)) (javaimp-node-children tree)))))) ;; Some API functions ;; do not expose tree structure, return only modules (defun javaimp-find-module (predicate) (let ((node (javaimp--find-node predicate))) (and node (javaimp-node-contents node)))) (defun javaimp-select-modules (predicate) (mapcar #'javaimp-node-contents (javaimp--select-nodes predicate))) ;;; Adding imports ;;;###autoload (defun javaimp-add-import (classname) "Imports classname in the current file. Interactively, asks for a class to import, adds import statement and calls `javaimp-organize-imports'. Import statements are not duplicated. Completion alternatives are constructed based on this module's dependencies' classes, jdk classes and top-level classes in the current module." (interactive (progn (barf-if-buffer-read-only) (let* ((file (expand-file-name (or buffer-file-name (error "Buffer is not visiting a file!")))) (node (or (javaimp--find-node (lambda (m) (or (string-prefix-p (javaimp-module-source-dir m) file) (string-prefix-p (javaimp-module-test-source-dir m) file)))) (error "Cannot find module by file: %s" file)))) (javaimp--maven-update-module-maybe node) (let ((module (javaimp-node-contents node))) (list (completing-read "Import: " (append ;; we're not caching full list of classes coming from module ;; dependencies because jars may change and we need to reload ;; them (let ((jars (append (javaimp-module-dep-jars module) (javaimp--get-jdk-jars)))) (apply #'seq-concatenate 'list (mapcar #'javaimp--get-jar-classes jars))) (and javaimp-include-current-module-classes (javaimp--get-module-classes module))) nil t nil nil (symbol-name (symbol-at-point)))))))) (javaimp-organize-imports (cons classname 'ordinary))) (defun javaimp--get-module-classes (module) "Returns list of top-level classes in current module" (append (let ((build-dir (javaimp-module-build-dir module))) ;; additional source dirs (and (seq-mapcat (lambda (rel-dir) (let ((dir (concat build-dir (file-name-as-directory rel-dir)))) (and (file-accessible-directory-p dir) (javaimp--get-directory-classes dir nil)))) javaimp-additional-source-dirs))) ;; source dir (let ((dir (javaimp-module-source-dir module))) (and (file-accessible-directory-p dir) (javaimp--get-directory-classes dir nil))) ;; test source dir (let ((dir (javaimp-module-test-source-dir module))) (and (file-accessible-directory-p dir) (javaimp--get-directory-classes dir nil))))) (defun javaimp--get-directory-classes (dir prefix) (append ;; .java files in current directory (mapcar (lambda (file) (concat prefix (file-name-sans-extension (car file)))) (seq-filter (lambda (file) (null (cadr file))) ;only files (directory-files-and-attributes dir nil "\\.java\\'" t))) ;; descend into subdirectories (apply #'seq-concatenate 'list (mapcar (lambda (subdir) (let ((name (car subdir))) (javaimp--get-directory-classes (concat dir (file-name-as-directory name)) (concat prefix name ".")))) (seq-filter (lambda (file) (and (eq (cadr file) t) ;only directories (null (member (car file) '("." ".."))))) (directory-files-and-attributes dir nil nil t)))))) ;; Organizing imports ;;;###autoload (defun javaimp-organize-imports (&rest new-imports) "Groups import statements according to the value of `javaimp-import-group-alist' (which see) and prints resulting groups leaving one blank line in between. Classes within a single group are ordered in a lexicographic order. Imports not matched by any regexp in `javaimp-import-group-alist' are assigned a default order defined by `javaimp-import-default-order'. NEW-IMPORTS is a list of additional imports; each element should be of the form (CLASS . TYPE), where CLASS is a string and TYPE is `'ordinary' or `'static'. Interactively, NEW-IMPORTS is nil." (interactive) (barf-if-buffer-read-only) (save-excursion (goto-char (point-min)) (let* ((old-data (javaimp--parse-imports)) (first (car old-data)) (last (cadr old-data)) (all-imports (append new-imports (cddr old-data)))) (if all-imports (progn ;; delete old imports, if any (if first (progn (goto-char last) (forward-line) (delete-region first (point)))) (javaimp--prepare-for-insertion first) (setq all-imports (delete-duplicates all-imports :test (lambda (first second) (equal (car first) (car second))))) ;; assign order (let ((with-order (mapcar (lambda (import) (let ((order (or (assoc-default (car import) javaimp-import-group-alist 'string-match) javaimp-import-default-order))) (cons import order))) all-imports))) (setq with-order (sort with-order (lambda (first second) ;; sort by order, name (if (= (cdr first) (cdr second)) (string< (caar first) (caar second)) (< (cdr first) (cdr second)))))) (javaimp--insert-imports with-order))) (message "Nothing to organize!"))))) (defun javaimp--parse-imports () (let (first last list) (while (re-search-forward "^\\s-*import\\s-+\\(static\\s-+\\)?\\([._[:word:]]+\\)" nil t) (push (cons (match-string 2) (if (match-string 1) 'static 'ordinary)) list) (setq last (line-beginning-position)) (or first (setq first last))) (cons first (cons last list)))) (defun javaimp--prepare-for-insertion (start) (cond (start ;; if there were any imports, we start inserting at the same place (goto-char start)) ((re-search-forward "^\\s-*package\\s-" nil t) ;; if there's a package directive, move to the next line, creating it ;; if needed (end-of-line) (if (eobp) (insert ?\n) (forward-line)) ;; then insert one blank line and we're done (insert ?\n)) (t ;; otherwise, just go to bob (goto-char (point-min))))) (defun javaimp--insert-imports (imports) (let ((static (seq-filter (lambda (elt) (eq (cdar elt) 'static)) imports)) (ordinary (seq-filter (lambda (elt) (eq (cdar elt) 'ordinary)) imports))) (javaimp--insert-import-group "import static %s;" static) (and static ordinary (insert ?\n)) (javaimp--insert-import-group "import %s;" ordinary))) (defun javaimp--insert-import-group (pattern imports) (let (last-order) (dolist (import imports) ;; if adjacent imports have different order value, insert a newline ;; between them (let ((order (cdr import))) (and last-order (/= order last-order) (insert ?\n)) (insert (format pattern (caar import)) ?\n) (setq last-order order))))) (provide 'javaimp) ;;; javaimp.el ends here