├── .gitignore ├── autoload ├── autotag.vim └── autotag.py ├── plugin └── autotag.vim └── README.markdown /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | */__pycache__ 3 | .*.sw? 4 | -------------------------------------------------------------------------------- /autoload/autotag.vim: -------------------------------------------------------------------------------- 1 | if ! has("python3") 2 | finish 3 | endif 4 | python3 import sys, os, vim 5 | python3 sys.path.insert(0, os.path.dirname(vim.eval('expand("")'))) 6 | python3 import autotag 7 | 8 | function! autotag#Run() 9 | if exists("b:netrw_method") 10 | return 11 | endif 12 | python3 autotag.autotag() 13 | if exists(":TlistUpdate") 14 | TlistUpdate 15 | endif 16 | endfunction 17 | -------------------------------------------------------------------------------- /plugin/autotag.vim: -------------------------------------------------------------------------------- 1 | " 2 | " (c) Craig Emery 2017-2022 3 | " 4 | " Increment the number below for a dynamic #include guard 5 | let s:autotag_vim_version=1 6 | 7 | if exists("g:autotag_vim_version_sourced") 8 | if s:autotag_vim_version == g:autotag_vim_version_sourced 9 | finish 10 | endif 11 | endif 12 | 13 | let g:autotag_vim_version_sourced=s:autotag_vim_version 14 | 15 | " This file supplies automatic tag regeneration when saving files 16 | " There's a problem with ctags when run with -a (append) 17 | " ctags doesn't remove entries for the supplied source file that no longer exist 18 | " so this script (implemented in Python) finds a tags file for the file vim has 19 | " just saved, removes all entries for that source file and *then* runs ctags -a 20 | 21 | if !has("python3") 22 | finish 23 | endif " !has("python3") 24 | 25 | function! AutoTagDebug() 26 | new 27 | file autotag_debug 28 | setlocal buftype=nowrite 29 | setlocal bufhidden=delete 30 | setlocal noswapfile 31 | normal  32 | endfunction 33 | 34 | augroup autotag 35 | au! 36 | autocmd BufWritePost,FileWritePost * call autotag#Run () 37 | augroup END 38 | 39 | " vim:shiftwidth=3:ts=3 40 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | autotag.vim 2 | ============ 3 | 4 | If you use ctags to make tags files of your source, it's nice to be able to re-run ctags on a source file when you save it. 5 | 6 | However, using `ctags -a` will only change existing entries in a tags file or add new ones. It doesn't delete entries that no longer exist. Should you delete an entity from your source file that's represented by an entry in a tags file, that entry will remain after calling `ctags -a`. 7 | 8 | This python function will do two things: 9 | 10 | 1) It will search for a tags file starting in the directory where your source file resides and moving up a directory at a time until it either finds one or runs out of directories to try. 11 | 12 | 2) Should it find a tags file, it will then delete all entries in said tags file referencing the source file you've just saved and then execute `ctags -a` on that source file using the relative path to the source file from the tags file. 13 | 14 | This way, every time you save a file, your tags file will be seamlessly updated. 15 | 16 | Installation 17 | ------------ 18 | 19 | Currently I suggest you use Vundle and install as a normal Bundle 20 | 21 | From the Vim command-line 22 | 23 | :BundleInstall 'craigemery/vim-autotag' 24 | 25 | And add to your ~/.vimrc 26 | 27 | Bundle 'craigemery/vim-autotag' 28 | 29 | Or you can manually install 30 | cd 31 | git clone git://github.com/craigemery/vim-autotag.git 32 | cd ~/.vim/ 33 | mkdir -p plugin 34 | cp ~/vim-autotag.git/plugin/* plugin/ 35 | 36 | ### Install as a Pathogen bundle 37 | ``` 38 | git clone git://github.com/craigemery/vim-autotag.git ~/.vim/bundle/vim-autotag 39 | ``` 40 | 41 | Getting round other ctags limitations 42 | ------------------------------------- 43 | ctags is very file name suffix driven. When the file has no suffix, ctags can fail to detect the file type. 44 | The easiest way to replicate this is when using a #! shebang. I've seen "#!/usr/bin/env python3" in a 45 | shebang not get detected by ctags. 46 | But Vim is better at this. So Vim's filetype buffer setting can help. 47 | So when the buffer being written has no suffix to the file name then the Vim filetype value will be used instead. 48 | So far I've only implemented "python" as one that is given to ctags --language-force= as is. 49 | Other filetypes could be mapped. There's a dict in the AutTag class. 50 | To not map a filetype to a forced language kind, add the vim file type to the comma "," separated 51 | list in autotagExcludeFiletypes. 52 | 53 | Configuration 54 | ------------- 55 | Autotag can be configured using the following global variables: 56 | 57 | | Name | Purpose | 58 | | ---- | ------- | 59 | | `g:autotagExcludeSuffixes` | suffixes to not ctags on | 60 | | `g:autotagExcludeFiletypes` | filetypes to not try & force a language choice on ctags | 61 | | `g:autotagVerbosityLevel` | logging verbosity (as in Python logging module) | 62 | | `g:autotagCtagsCmd` | name of ctags command | 63 | | `g:autotagTagsFile` | name of tags file to look for | 64 | | `g:autotagDisabled` | Disable autotag (enable by setting to any non-blank value) | 65 | | `g:autotagStopAt` | stop looking for a tags file (and make one) at this directory (defaults to $HOME) | 66 | | `g:autotagStartMethod` | Now AutoTag uses Python multiprocessing, the start method is an internal aspect that Python uses. 67 | 68 | These can be overridden with buffer specific ones. b: instead of g: 69 | Example: 70 | ``` 71 | let g:autotagTagsFile=".tags" 72 | ``` 73 | 74 | macOS, Python 3.8 and 'spawn' 75 | ----------------------------- 76 | With the release of Python 3.8, the default start method for multiprocessing on macOS has become 'spawn' 77 | At the time of writing there are issues with 'spawn' and I advise making AutoTag ask Python to use 'fork' 78 | i.e. before loading the plugin: 79 | ``` 80 | let g:autotagStartMethod='fork' 81 | ``` 82 | 83 | Self-Promotion 84 | -------------- 85 | 86 | Like autotag.vim? Follow the repository on 87 | [GitHub](https://github.com/craigemery/vim-autotag) and vote for it on 88 | [vim.org](http://www.vim.org/scripts/script.php?script_id=1343). And if 89 | you're feeling especially charitable, follow [craigemery] on 90 | [GitHub](https://github.com/craigemery). 91 | 92 | License 93 | ------- 94 | 95 | Copyright (c) Craig Emery. Distributed under the same terms as Vim itself. 96 | See `:help license`. 97 | -------------------------------------------------------------------------------- /autoload/autotag.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) Craig Emery 2017-2022 3 | AutoTag.py 4 | """ 5 | 6 | from __future__ import print_function 7 | import sys 8 | import os 9 | import fileinput 10 | import logging 11 | from collections import defaultdict 12 | import subprocess 13 | from traceback import format_exc 14 | import multiprocessing as mp 15 | from glob import glob 16 | import vim # pylint: disable=import-error 17 | 18 | __all__ = ["autotag"] 19 | 20 | # global vim config variables used (all are g:autotag): 21 | # name purpose 22 | # ExcludeSuffixes suffixes to not ctags on 23 | # VerbosityLevel logging verbosity (as in Python logging module) 24 | # CtagsCmd name of ctags command 25 | # TagsFile name of tags file to look for 26 | # Disabled Disable autotag (enable by setting to any non-blank value) 27 | # StopAt stop looking for a tags file (and make one) at this directory (defaults to $HOME) 28 | GLOBALS_DEFAULTS = dict(ExcludeSuffixes="tml.xml.text.txt", 29 | VerbosityLevel=logging.WARNING, 30 | CtagsCmd="ctags", 31 | TagsFile="tags", 32 | TagsDir="", 33 | Disabled=0, 34 | StopAt=0, 35 | StartMethod="") 36 | 37 | 38 | def do_cmd(cmd, cwd): 39 | """ Abstract subprocess """ 40 | with subprocess.Popen(cmd, cwd=cwd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, 41 | stderr=subprocess.PIPE, universal_newlines=True) as proc: 42 | stdout = proc.communicate()[0] 43 | return stdout.split("\n") 44 | 45 | 46 | def vim_global(name, kind=str): 47 | """ Get global variable from vim, cast it appropriately """ 48 | ret = GLOBALS_DEFAULTS.get(name, None) 49 | try: 50 | vname = "autotag" + name 51 | v_buffer = "b:" + vname 52 | exists_buffer = (vim.eval(f"exists('{v_buffer}')") == "1") 53 | v_global = "g:" + vname 54 | exists_global = (vim.eval(f"exists('{v_global}')") == "1") 55 | if exists_buffer: 56 | ret = vim.eval(v_buffer) 57 | elif exists_global: 58 | ret = vim.eval(v_global) 59 | else: 60 | if isinstance(ret, int): 61 | vim.command(f"let {v_global}={ret}") 62 | else: 63 | vim.command(f"let {v_global}=\"{ret}\"") 64 | finally: 65 | if kind == bool: 66 | ret = (ret in [1, "1", "true", "yes"]) 67 | elif kind == int: 68 | try: 69 | val = int(ret) 70 | except TypeError: 71 | val = ret 72 | except ValueError: 73 | val = ret 74 | ret = val 75 | elif kind == str: 76 | ret = str(ret) 77 | return ret 78 | 79 | 80 | def init_multiprocessing(): 81 | """ Init multiprocessing, set_executable() & get the context we'll use """ 82 | wanted_start_method = vim_global("StartMethod") or None 83 | used_start_method = mp.get_start_method() 84 | if wanted_start_method in mp.get_all_start_methods(): 85 | used_start_method = wanted_start_method 86 | else: 87 | wanted_start_method = None 88 | # here wanted_start_method is either a valid method or None 89 | # used_start_method is what the module has as the default or our overriden value 90 | ret = mp.get_context(wanted_start_method) # wanted_start_method might be None 91 | try: 92 | mp.set_executable 93 | except AttributeError: 94 | return ret 95 | if used_start_method == 'spawn': 96 | suff = os.path.splitext(sys.executable)[1] 97 | pat1 = f"python*{suff}" 98 | pat2 = os.path.join("bin", pat1) 99 | exes = glob(os.path.join(sys.exec_prefix, pat1)) + glob(os.path.join(sys.exec_prefix, pat2)) 100 | if exes: 101 | win = [exe for exe in exes if exe.endswith(f"w{suff}")] 102 | if win: 103 | # In Windows pythonw.exe is best 104 | ret.set_executable(win[0]) 105 | else: 106 | # This isn't great, for now pick the first one 107 | ret.set_executable(exes[0]) 108 | return ret 109 | 110 | 111 | CTX = init_multiprocessing() 112 | 113 | 114 | class VimAppendHandler(logging.Handler): 115 | """ Logger handler that finds a buffer and appends the log message as a new line """ 116 | def __init__(self, name): 117 | logging.Handler.__init__(self) 118 | self.__name = name 119 | self.__formatter = logging.Formatter() 120 | 121 | def __find_buffer(self): 122 | """ Look for the named buffer """ 123 | for buff in vim.buffers: 124 | if buff and buff.name and buff.name.endswith(self.__name): 125 | yield buff 126 | 127 | def emit(self, record): 128 | """ Emit the logging message """ 129 | for buff in self.__find_buffer(): 130 | buff.append(self.__formatter.format(record)) 131 | 132 | 133 | def set_logger_verbosity(): 134 | """ Set the verbosity of the logger """ 135 | level = vim_global("VerbosityLevel", kind=int) 136 | LOGGER.setLevel(level) 137 | 138 | 139 | def make_and_add_handler(logger, name): 140 | """ Make the handler and add it to the standard logger """ 141 | ret = VimAppendHandler(name) 142 | logger.addHandler(ret) 143 | return ret 144 | 145 | 146 | try: 147 | LOGGER 148 | except NameError: 149 | DEBUG_NAME = "autotag_debug" 150 | LOGGER = logging.getLogger(DEBUG_NAME) 151 | HANDLER = make_and_add_handler(LOGGER, DEBUG_NAME) 152 | set_logger_verbosity() 153 | 154 | 155 | class AutoTag(): # pylint: disable=too-many-instance-attributes 156 | """ Class that does auto ctags updating """ 157 | LOG = LOGGER 158 | AUTOFILETYPES = ["python"] 159 | FILETYPES = {} 160 | 161 | def __init__(self): 162 | self.locks = {} 163 | self.tags = defaultdict(list) 164 | self.excludesuffix = ["." + s for s in vim_global("ExcludeSuffixes").split(".")] 165 | self.excludefiletype = vim_global("ExcludeFiletypes").split(",") 166 | set_logger_verbosity() 167 | self.sep_used_by_ctags = '/' 168 | self.ctags_cmd = vim_global("CtagsCmd") 169 | self.tags_file = str(vim_global("TagsFile")) 170 | self.tags_dir = str(vim_global("TagsDir")) 171 | self.parents = os.pardir * (len(os.path.split(self.tags_dir)) - 1) 172 | self.count = 0 173 | self.stop_at = vim_global("StopAt") 174 | 175 | def find_tag_file(self, source): 176 | """ Find the tag file that belongs to the source file """ 177 | AutoTag.LOG.info('source = "%s"', source) 178 | (drive, fname) = os.path.splitdrive(source) 179 | ret = None 180 | while ret is None: 181 | fname = os.path.dirname(fname) 182 | AutoTag.LOG.info('drive = "%s", file = "%s"', drive, fname) 183 | tags_dir = os.path.join(drive, fname) 184 | tags_file = os.path.join(tags_dir, self.tags_dir, self.tags_file) 185 | AutoTag.LOG.info('testing tags_file "%s"', tags_file) 186 | if os.path.isfile(tags_file): 187 | stinf = os.stat(tags_file) 188 | if stinf: 189 | size = getattr(stinf, 'st_size', None) 190 | if size is None: 191 | AutoTag.LOG.warning("Could not stat tags file %s", tags_file) 192 | ret = "" 193 | ret = (fname, tags_file) 194 | elif tags_dir and tags_dir == self.stop_at: 195 | AutoTag.LOG.info("Reached %s. Making one %s", self.stop_at, tags_file) 196 | open(tags_file, 'wb').close() 197 | ret = (fname, tags_file) 198 | ret = "" 199 | elif not fname or fname == os.sep or fname == "//" or fname == "\\\\": 200 | AutoTag.LOG.info('bail (file = "%s")', fname) 201 | ret = "" 202 | return ret or None 203 | 204 | def add_source(self, source, filetype): 205 | """ Make a note of the source file, ignoring some etc """ 206 | if not source: 207 | AutoTag.LOG.warning('No source') 208 | return 209 | if os.path.basename(source) == self.tags_file: 210 | AutoTag.LOG.info("Ignoring tags file %s", self.tags_file) 211 | return 212 | suff = os.path.splitext(source)[1] 213 | if suff: 214 | AutoTag.LOG.info("Source %s has suffix %s, so filetype doesn't count!", source, suff) 215 | filetype = None 216 | else: 217 | AutoTag.LOG.info("Source %s has no suffix, so filetype counts!", source) 218 | 219 | if suff in self.excludesuffix: 220 | AutoTag.LOG.info("Ignoring excluded suffix %s for file %s", suff, source) 221 | return 222 | if filetype in self.excludefiletype: 223 | AutoTag.LOG.info("Ignoring excluded filetype %s for file %s", filetype, source) 224 | return 225 | found = self.find_tag_file(source) 226 | if found: 227 | (tags_dir, tags_file) = found 228 | relative_source = os.path.splitdrive(source)[1][len(tags_dir):] 229 | if relative_source[0] == os.sep: 230 | relative_source = relative_source[1:] 231 | if os.sep != self.sep_used_by_ctags: 232 | relative_source = relative_source.replace(os.sep, self.sep_used_by_ctags) 233 | key = (tags_dir, tags_file, filetype) 234 | self.tags[key].append(relative_source) 235 | if key not in self.locks: 236 | self.locks[key] = CTX.Lock() 237 | 238 | @staticmethod 239 | def good_tag(line, excluded): 240 | """ Filter method for stripping tags """ 241 | if line[0] == '!': 242 | return True 243 | fields = line.split('\t') 244 | AutoTag.LOG.log(1, "read tags line:%s", str(fields)) 245 | if len(fields) > 3 and fields[1] not in excluded: 246 | return True 247 | return False 248 | 249 | def strip_tags(self, tags_file, sources): 250 | """ Strip all tags for a given source file """ 251 | AutoTag.LOG.info("Stripping tags for %s from tags file %s", ",".join(sources), tags_file) 252 | backup = ".SAFE" 253 | try: 254 | with fileinput.FileInput(files=tags_file, inplace=True, backup=backup) as source: 255 | for line in source: 256 | line = line.strip() 257 | if self.good_tag(line, sources): 258 | print(line) 259 | finally: 260 | try: 261 | os.unlink(tags_file + backup) 262 | except IOError: 263 | pass 264 | 265 | def _vim_ft_to_ctags_ft(self, name): 266 | """ convert vim filetype strings to ctags strings """ 267 | if name in AutoTag.AUTOFILETYPES: 268 | return name 269 | return self.FILETYPES.get(name, None) 270 | 271 | def update_tags_file(self, key, sources): 272 | """ Strip all tags for the source file, then re-run ctags in append mode """ 273 | (tags_dir, tags_file, filetype) = key 274 | lock = self.locks[key] 275 | if self.tags_dir: 276 | sources = [os.path.join(self.parents + s) for s in sources] 277 | cmd = [self.ctags_cmd] 278 | if self.tags_file: 279 | cmd += ["-f", self.tags_file] 280 | if filetype: 281 | ctags_filetype = self._vim_ft_to_ctags_ft(filetype) 282 | if ctags_filetype: 283 | cmd += [f"--language-force={ctags_filetype}"] 284 | cmd += ["-a"] 285 | 286 | def is_file(src): 287 | """ inner """ 288 | return os.path.isfile(os.path.join(tags_dir, self.tags_dir, src)) 289 | 290 | srcs = list(filter(is_file, sources)) 291 | if not srcs: 292 | return 293 | 294 | cmd += [f'"{s}"' for s in srcs] 295 | cmd = " ".join(cmd) 296 | with lock: 297 | self.strip_tags(tags_file, sources) 298 | AutoTag.LOG.log(1, "%s: %s", tags_dir, cmd) 299 | for line in do_cmd(cmd, self.tags_dir or tags_dir): 300 | AutoTag.LOG.log(10, line) 301 | 302 | def rebuild_tag_files(self): 303 | """ rebuild the tags file thread worker """ 304 | for (key, sources) in self.tags.items(): 305 | AutoTag.LOG.info('Process(%s, %s)', key, ",".join(sources)) 306 | proc = CTX.Process(target=self.update_tags_file, args=(key, sources)) 307 | proc.daemon = True 308 | proc.start() 309 | 310 | 311 | def autotag(): 312 | """ Do the work """ 313 | try: 314 | if not vim_global("Disabled", bool): 315 | runner = AutoTag() 316 | runner.add_source(vim.eval("expand(\"%:p\")"), vim.eval("&ft")) 317 | runner.rebuild_tag_files() 318 | except Exception: # pylint: disable=broad-except 319 | logging.warning(format_exc()) 320 | --------------------------------------------------------------------------------