""" " HACK to make it possible to reload this by sourcing it in vim py3 import importlib; importlib.reload(python_imports) finish """ import re import os from typing import NamedTuple, Iterable, Optional import vim DEFAULT_CONFIG_FILE = os.path.expanduser('~/.vim/python-imports.cfg') WS = r'\s+' MAYBE_WS = r'\s*' DOTTEDNAME = r'[a-zA-Z_.][a-zA-Z_0-9.]*' NAME = r'[a-zA-Z_][a-zA-Z_0-9]*' NAMEWITHALIAS = NAME + r'(?:' + WS + 'as' + WS + NAME + ')?' NAMELIST = NAMEWITHALIAS + r'(?:' + MAYBE_WS + r',' + MAYBE_WS + NAMEWITHALIAS + r')*' # technically this allows unbalanced parens like 'import (aaaa' or 'import a, b)'; # I don't care NAMES = r'[(]?' + MAYBE_WS + '(?P' + NAMELIST + r')' + MAYBE_WS + r',?' + MAYBE_WS + r'[)]?' MODNAME = r'(?P' + DOTTEDNAME + r')' # technically 'import (a, b, c)' should not be allowed; I don't care IMPORT_RX = re.compile(r'^import' + WS + NAMES + r'$') # technically 'from foo import(a, b, c)' should be allowed without whitespace # before the (, but I don't care FROM_IMPORT_RX = re.compile(r'^from' + WS + MODNAME + WS + r'import' + WS + NAMES + r'$') class Error(Exception): """Syntax error in the config file.""" class Line(str): """Line that remembers its source location.""" filename = None lineno = None def with_location(self, filename: Optional[str], lineno: Optional[int]) -> 'Line': self.filename = filename self.lineno = lineno return self def with_same_location_as(self, line: 'Line') -> 'Line': self.filename = line.filename self.lineno = line.lineno return self class ImportedName(NamedTuple): """Information about the canonical location of an import.""" modname: str # fully qualified module (or package) name (can be blank) name: str # name of the importable thing alias: str # alias to give to the importable thing (often same as `name`) @property def has_alias(self) -> bool: return self.alias != self.name def __str__(self) -> str: bits = [self.name] if self.has_alias: bits.append(f"as {self.alias}") if self.modname: bits.append(f"from {self.modname}") return " ".join(bits) def parse_names(names: str, modname: str = '') -> Iterable[ImportedName]: """Parse a list of imported names. The grammar is:: names ::= [as ] [, ] """ for name in names.split(','): bits = name.split() # it's either [name] or [name, 'as', alias], and the following works # for both yield ImportedName(modname, bits[0], bits[-1]) def parse_line(line: Line) -> Iterable[ImportedName]: """Parse an import configuration line. The grammar is:: line ::= import | from import """ m = IMPORT_RX.match(line) if m: return parse_names(m.group('names')) m = FROM_IMPORT_RX.match(line) if m: modname = m.group('modname') return parse_names(m.group('names'), modname) raise Error(f'could not parse line {line.lineno}: {line}') def strip_comments(lines: Iterable[str], filename: Optional[str] = None) -> Iterable[Line]: """Strip whitespace and comments and return nonblank lines.""" for n, line in enumerate(lines, 1): line = line.partition('#')[0].strip() if line: yield Line(line).with_location(filename, n) def joined_lines(lines: Iterable[Line]) -> Iterable[Line]: """Join lines until parentheses match.""" balance = 0 buffer = [] for line in lines: buffer.append(line) balance += line.count('(') - line.count(')') if balance == 0: yield Line(' '.join(buffer)).with_same_location_as(buffer[0]) del buffer[:] if buffer: # this is unbalanced, but let's let the next level report the error yield Line(' '.join(buffer)).with_same_location_as(buffer[0]) def parse_python_imports_cfg(filename: str = DEFAULT_CONFIG_FILE, verbose: bool = False) -> None: """Parse python-imports.cfg if it exists. Stores the parsed configuration directly in vim's g:pythonImports and g:pythonImportAliases global variables, which must exist and be defined as dictionaries. """ try: with open(filename) as f: for line in joined_lines(strip_comments(f, filename)): for name in parse_line(line): if verbose >= 2: print(name) vim.command("let g:pythonImports['%s'] = '%s'" % (name.name, name.modname)) if name.has_alias: vim.command("let g:pythonImportAliases['%s'] = '%s'" % (name.alias, name.name)) continue except (Error, IOError) as e: if verbose: print("Failed to load %s: %s" % (filename, e))