diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 00000000..bb80d3b6
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,79 @@
+# Python CircleCI 2.0 configuration file
+#
+# Check https://circleci.com/docs/2.0/language-python/ for more details
+#
+version: 2
+
+common: &common
+ working_directory: ~/repo
+ steps:
+ - checkout
+ - restore_cache:
+ keys:
+ - v1-deps-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}
+ - v1-deps-
+ - run: pip install --user tox
+ - run: ~/.local/bin/tox
+ - run:
+ name: upload coverage results for non-checkqa builds
+ command: |
+ if [[ "$TOXENV" != checkqa ]]; then
+ PATH=$HOME/.local/bin:$PATH
+ # XXX: use bash script?!
+ pip install --user codecov
+ coverage xml
+ coverage report -m
+ codecov --required -X search gcov pycov -f coverage.xml --flags $CIRCLE_JOB
+
+ if [[ "$CIRCLE_JOB" == py36 ]]; then
+ pip install --user coveralls
+ coveralls
+ fi
+ set +x
+ fi
+ - save_cache:
+ paths:
+ - .tox
+ - ~/.cache/pip
+ key: v1-deps-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}
+jobs:
+ py36:
+ <<: *common
+ docker:
+ - image: circleci/python:3.6
+ environment:
+ TOXENV=py36-coverage
+ py35:
+ <<: *common
+ docker:
+ - image: circleci/python:3.5
+ environment:
+ TOXENV=py35-coverage
+ py34:
+ <<: *common
+ docker:
+ - image: circleci/python:3.4
+ environment:
+ TOXENV=py34-coverage
+ py27:
+ <<: *common
+ docker:
+ - image: circleci/python:2.7
+ environment:
+ TOXENV=py27-coverage
+ checkqa:
+ <<: *common
+ docker:
+ - image: circleci/python:3.6
+ environment:
+ TOXENV=checkqa
+
+workflows:
+ version: 2
+ test:
+ jobs:
+ - py36
+ - py35
+ - py34
+ - py27
+ - checkqa
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..0496fc03
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,7 @@
+[run]
+source = .
+include = covimerage/*,tests/*
+branch = true
+
+[report]
+show_missing = true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..567609b1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..8e95691f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,100 @@
+test:
+ tox -e py36
+
+VIM:=$(shell command -v nvim || echo vim)
+
+test_integration:
+ tox -e integration
+
+# Fixture generation.
+PROFILES_TO_MERGE_COND:=tests/fixtures/merged_conditionals-0.profile \
+ tests/fixtures/merged_conditionals-1.profile \
+ tests/fixtures/merged_conditionals-2.profile
+fixtures: tests/fixtures/test_plugin.vim.profile
+fixtures: tests/fixtures/test_plugin.nvim.profile
+fixtures: tests/fixtures/dict_function.profile
+fixtures: tests/fixtures/dict_function_with_same_source.profile
+fixtures: tests/fixtures/dict_function_with_continued_lines.profile
+fixtures: tests/fixtures/dict_function_used_twice.profile
+fixtures: tests/fixtures/continued_lines.profile
+fixtures: tests/fixtures/conditional_function.profile
+fixtures: $(PROFILES_TO_MERGE_COND)
+
+# TODO: cleanup. Should be handled by the generic rule at the bottom.
+tests/fixtures/dict_function.profile: tests/test_plugin/dict_function.vim
+ $(VIM) --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c 'source $<' -c q
+ sed -i 's:^SCRIPT .*/test_plugin:SCRIPT /test_plugin:' $@
+
+tests/fixtures/dict_function_with_same_source.profile: test_plugin/dict_function_with_same_source.vim
+ $(VIM) --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c 'source $<' -c q
+ sed -i 's:^SCRIPT .*/test_plugin:SCRIPT /test_plugin:' $@
+
+tests/fixtures/test_plugin.vim.profile: test_plugin/autoload/test_plugin.vim
+ vim --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c q
+
+tests/fixtures/test_plugin.nvim.profile: test_plugin/autoload/test_plugin.vim
+ nvim --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c q
+
+PROFILES_TO_MERGE:=tests/fixtures/merge-1.profile tests/fixtures/merge-2.profile
+$(PROFILES_TO_MERGE): test_plugin/merged_profiles.vim test_plugin/merged_profiles-init.vim Makefile
+ $(VIM) -Nu test_plugin/merged_profiles-init.vim -c q
+ sed -i 's:^SCRIPT .*/test_plugin:SCRIPT /test_plugin:' $(PROFILES_TO_MERGE)
+
+PROFILES_TO_MERGE_COND:=tests/fixtures/merged_conditionals-0.profile \
+ tests/fixtures/merged_conditionals-1.profile \
+ tests/fixtures/merged_conditionals-2.profile
+$(PROFILES_TO_MERGE_COND): tests/test_plugin/merged_conditionals.vim Makefile
+ for cond in 0 1 2; do \
+ $(VIM) --noplugin -Nu tests/t.vim \
+ --cmd "let g:prof_fname = 'tests/fixtures/merged_conditionals-$$cond.profile'" \
+ --cmd "let test_conditional = $$cond" \
+ -c "source $<" -c q; \
+ done
+ sed -i 's:^SCRIPT .*/test_plugin:SCRIPT tests/test_plugin:' $(PROFILES_TO_MERGE_COND)
+
+tests/fixtures/%.profile: tests/test_plugin/%.vim Makefile
+ $(VIM) --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c 'source $<' -c q
+ sed -i 's:^SCRIPT .*/test_plugin:SCRIPT tests/test_plugin:' $@
+
+
+# Helpers to generate (combined) coverage and show a diff {{{
+#
+# Use `make coverage-diff` to diff coverage diff to the old state
+# (recorded via `make coverage-save`).
+
+MAIN_COVERAGE:=build/coverage
+
+coverage: $(MAIN_COVERAGE)
+ COVERAGE_FILE=$< coverage report -m
+
+coverage-save: | build
+ cp -a $(MAIN_COVERAGE) build/coverage.old
+
+coverage-diff: build/covreport.old
+coverage-diff: build/covreport.new
+coverage-diff:
+ @diff --color=always -u $^ | /usr/share/git/diff-highlight/diff-highlight | sed 1,3d
+ @#git --no-pager diff --no-index --color-words build/covreport.old build/covreport.new | sed 1,5d
+ @# git --no-pager diff --color --no-index build/covreport.old build/covreport.new | sed 1,5d | diff-so-fancy
+
+.PHONY: coverage coverage-save coverage-diff
+
+$(MAIN_COVERAGE): $(shell find covimerage tests -name '*.py') | build
+ COVERAGE_FILE=$@ tox -e coverage.pytest
+
+build/coverage.old:
+ $(MAKE) coverage-save
+
+build/covreport.old: build/coverage.old | build
+ COVERAGE_FILE=$< coverage report -m > $@ || { ret=$$?; cat $@; exit $$ret; }
+
+build/covreport.new: $(MAIN_COVERAGE) | build
+ COVERAGE_FILE=$< coverage report -m > $@ || { ret=$$?; cat $@; exit $$ret; }
+# }}}
+
+tags:
+ rg --files-with-matches . | ctags --links=no -L-
+.PHONY: tags
+
+build:
+ mkdir -p $@
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..8df2438e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# covimerage
+
+Generates code coverage information for Vim scripts.
+
+It parses the output from Vim's `:profile` command, and generates data
+compatible with [Coverage.py](http://coverage.readthedocs.io/).
+
+**NOTE:** this `develop` branch will be squash-merged into master after some
+stabilization (1-2 weeks).
+
+[![CircleCI](https://circleci.com/gh/Vimjas/covimerage.svg?style=svg)](https://circleci.com/gh/Vimjas/covimerage)
+[![codecov](https://codecov.io/gh/Vimjas/covimerage/branch/develop/graph/badge.svg)](https://codecov.io/gh/Vimjas/covimerage/branch/develop)
+
+## Installation
+
+You can install covimerage using pip:
+
+```sh
+pip install covimerage
+```
+
+## Simple usage
+
+You can use `covimerage run` to wrap the call to Neovim/Vim with necessary
+boilerplate:
+
+```sh
+covimerage run vim -Nu test/vimrc -c 'Vader! test/**'
+```
+
+This will write the file `.coverage.covimerage` by default (use `--data-file`
+to configure it), which is compatible to Coverage.py.
+A report is automatically generated (on stdout).
+
+You can then call `covimerage xml` to create a `coverage.xml` file
+(Cobertura-compatible), which tools like [Codecov](https://codecov.io/)'s
+`codecov` tool can consume, e.g. via `codecov -f coverage.xml`.
+
+## Manual/advanced usage
+
+### 1. Generate profile information for your Vim script(s)
+
+You have to basically add the following to your tests vimrc:
+
+```vim
+profile start /tmp/vim-profile.txt
+profile! file ./*
+```
+
+This makes Neovim/Vim then write a file with profiling information.
+
+### 2. Call covimerage on the output file(s)
+
+```sh
+covimerage write_coverage /tmp/vim-profile.txt
+```
+
+This will create a file `.coverage.covimerage` (the default for `--data-file`),
+with entries marked for processing by a
+[Coverage.py](http://coverage.readthedocs.io/) plugin (provided by
+covimerage)).
+
+### 3. Include the covimerage plugin in .coveragerc
+
+When using `coverage` on the generated output (data file), you need to add
+the `covimerage` plugin to the `.coveragerc` file (which Coverage.py uses).
+This is basically all the `.coveragerc` you will need, but you could use
+other settings here (for Coverage.py), e.g. to omit some files:
+
+```
+[run]
+plugins = covimerage
+data_file = .coverage.covimerage
+```
+
+### 4. Create the report(s)
+
+You can now call e.g. `coverage report -m`, and you should be able to use
+coverage reporting platforms like or
+, which are basically using `coverage xml`.
+
+## Reference implementation
+
+- [Neomake](https://github.com/neomake/neomake) is the first adopter of this.
+ It has an advanced test setup (including Docker based builds), and looking at
+ tis setup could be helpful when setting up covimerage for your
+ plugin/project.
+
+ - [Neomake's coverage report on codecov.io](https://codecov.io/gh/neomake/neomake/tree/master)
+ - [PR/change to integrate it in
+ Neomake](https://github.com/neomake/neomake/pull/1600) (Neomake's test
+ setup is rather advanced, so do not let that scare you!)
+
+## Links
+
+- Discussion in Coverage.py's issue tracker:
+ [coverage issue 607](https://bitbucket.org/ned/coveragepy/issues/607/)
+
+## TODO
+
+- Line hit counts: known to covimerage, but not supported by Coverage.py
+ ().
diff --git a/covimerage/__init__.py b/covimerage/__init__.py
new file mode 100755
index 00000000..bec099e8
--- /dev/null
+++ b/covimerage/__init__.py
@@ -0,0 +1,500 @@
+"""The main covimerage module."""
+import copy
+import itertools
+import os
+import re
+
+import attr
+from click.utils import string_types
+
+from .coveragepy import CoverageData
+from .logger import LOGGER
+from .utils import (
+ find_executable_files, get_fname_and_fobj_and_str, is_executable_line,
+)
+
+DEFAULT_COVERAGE_DATA_FILE = '.coverage.covimerage'
+RE_FUNC_PREFIX = re.compile(
+ r'^\s*fu(?:n(?:(?:c(?:t(?:i(?:o(?:n)?)?)?)?)?)?)?!?\s+')
+RE_CONTINUING_LINE = r'\s*\\'
+
+
+@attr.s
+class Line(object):
+ """A source code line."""
+ line = attr.ib()
+ count = attr.ib(default=None)
+ total_time = attr.ib(default=None)
+ self_time = attr.ib(default=None)
+ _parsed_line = None
+
+
+@attr.s(hash=True)
+class Script(object):
+ path = attr.ib()
+ lines = attr.ib(default=attr.Factory(dict), repr=False, hash=False)
+ # List of line numbers for dict functions.
+ dict_functions = attr.ib(default=attr.Factory(set), repr=False, hash=False)
+ # List of line numbers for dict functions that have been mapped already.
+ mapped_dict_functions = attr.ib(default=attr.Factory(set), repr=False,
+ hash=False)
+ func_to_lnums = attr.ib(default=attr.Factory(dict), repr=False, hash=False)
+ sourced_count = attr.ib(default=None)
+
+ def parse_script_line(self, lnum, line):
+ m = re.match(RE_FUNC_PREFIX, line)
+ if m:
+ f = line[m.end():].split('(', 1)[0]
+ if '.' in f:
+ self.dict_functions.add(lnum)
+
+ if f.startswith(''):
+ f = 's:' + f[5:]
+ elif f.startswith('g:'):
+ f = f[2:]
+ self.func_to_lnums.setdefault(f, []).append(lnum)
+
+
+@attr.s
+class Function(object):
+ name = attr.ib()
+ calls = attr.ib(default=None)
+ total_time = attr.ib(default=None)
+ self_time = attr.ib(default=None)
+ lines = attr.ib(default=attr.Factory(dict), repr=False)
+
+
+@attr.s
+class MergedProfiles(object):
+ profiles = attr.ib(default=attr.Factory(list))
+ source = attr.ib(default=attr.Factory(list))
+ append_to = attr.ib(default=None)
+
+ _coveragepy_data = None
+
+ def __setattr__(self, name, value):
+ """Invalidate cache if profiles get changed."""
+ if name == 'profiles':
+ self._coveragepy_data = None
+ super(MergedProfiles, self).__setattr__(name, value)
+
+ def add_profile_files(self, *profile_files):
+ for f in profile_files:
+ p = Profile(f)
+ p.parse()
+ self.profiles.append(p)
+
+ @property
+ def scripts(self):
+ return itertools.chain.from_iterable(p.scripts for p in self.profiles)
+
+ @property
+ def scriptfiles(self):
+ return {s.path for s in self.scripts}
+
+ @property
+ def lines(self):
+ def merge_lines(line1, line2):
+ assert line1.line == line2.line
+ new = Line(line1.line)
+ if line1.count is None:
+ new.count = line2.count
+ elif line2.count is None:
+ new.count = line1.count
+ else:
+ new.count = line1.count + line2.count
+ return new
+
+ lines = {}
+ for p in self.profiles:
+ for s, s_lines in p.lines.items():
+ if s.path in lines:
+ new = lines[s.path]
+ for lnum, line in s_lines.items():
+ if lnum in new:
+ new[lnum] = merge_lines(new[lnum], line)
+ else:
+ new[lnum] = copy.copy(line)
+ else:
+ lines[s.path] = copy.copy(s_lines)
+
+ if s.sourced_count and s_lines:
+ # Fix line count for first line.
+ # https://github.com/vim/vim/issues/2103.
+ line1 = lines[s.path][1]
+ if not line1.count and is_executable_line(line1.line):
+ line1.count = 1
+ return lines
+
+ def _get_coveragepy_data(self):
+ if self.append_to:
+ fname, fobj, fstr = get_fname_and_fobj_and_str(self.append_to)
+ if fobj or (fname and os.path.exists(fname)):
+ data = CoverageData(data_file=self.append_to)
+ else:
+ data = CoverageData()
+ else:
+ data = CoverageData()
+
+ cov_dict = {}
+ cov_file_tracers = {}
+
+ source_files = []
+ for source in self.source:
+ source = os.path.abspath(source)
+ if os.path.isfile(source):
+ source_files.append(source)
+ else:
+ source_files.extend(find_executable_files(source))
+ LOGGER.debug('source_files: %r', source_files)
+
+ for fname, lines in self.lines.items():
+ fname = os.path.abspath(fname)
+ if self.source and fname not in source_files:
+ LOGGER.info('Ignoring non-source: %s', fname)
+ continue
+
+ cov_dict[fname] = {
+ # lnum: line.count for lnum, line in lines.items()
+ # XXX: coveragepy does not support hit counts?!
+ lnum: None for lnum, line in lines.items() if line.count
+ }
+ # Add our plugin as file tracer, so that it gets used with e.g.
+ # `coverage annotate`.
+ cov_file_tracers[fname] = 'covimerage.CoveragePlugin'
+ measured_files = cov_dict.keys()
+ non_measured_files = set(source_files) - set(measured_files)
+ for fname in non_measured_files:
+ LOGGER.debug('Non-measured file: %s', fname)
+ cov_dict[fname] = {}
+ cov_file_tracers[fname] = 'covimerage.CoveragePlugin'
+
+ data.add_lines(cov_dict)
+ data.cov_data.add_file_tracers(cov_file_tracers)
+ return data.cov_data
+
+ def get_coveragepy_data(self):
+ if self._coveragepy_data is not None:
+ return self._coveragepy_data
+ else:
+ self._coveragepy_data = self._get_coveragepy_data()
+ return self._coveragepy_data
+
+ # TODO: move to CoverageWrapper
+ def write_coveragepy_data(self, data_file='.coverage'):
+ cov_data = self.get_coveragepy_data()
+ if not cov_data.line_counts():
+ LOGGER.warning('Not writing coverage file: no data to report!')
+ return False
+
+ if isinstance(data_file, string_types):
+ LOGGER.info('Writing coverage file %s.', data_file)
+ cov_data.write_file(data_file)
+ else:
+ try:
+ filename = data_file.name
+ except AttributeError:
+ filename = str(data_file)
+ LOGGER.info('Writing coverage file %s.', filename)
+ cov_data.write_fileobj(data_file)
+ return True
+
+
+@attr.s
+class Profile(object):
+ fname = attr.ib()
+ scripts = attr.ib(default=attr.Factory(list))
+ anonymous_functions = attr.ib(default=attr.Factory(dict))
+
+ def __attrs_post_init__(self):
+ self.fname, self.fobj, self.fstr = get_fname_and_fobj_and_str(
+ self.fname)
+
+ @property
+ def scriptfiles(self):
+ return {s.path for s in self.scripts}
+
+ @property
+ def lines(self):
+ return {s: s.lines for s in self.scripts}
+
+ def _get_anon_func_script_line(self, func):
+ len_func_lines = len(func.lines)
+ found = []
+ for s in self.scripts:
+ for lnum in s.dict_functions:
+ script_lnum = lnum + 1
+ len_script_lines = len(s.lines)
+
+ func_lnum = 0
+ script_is_func = True
+ script_line = s.lines[script_lnum].line
+ while (script_lnum <= len_script_lines and
+ func_lnum < len_func_lines):
+ script_lnum += 1
+ next_line = s.lines[script_lnum].line
+ m = re.match(RE_CONTINUING_LINE, next_line)
+ if m:
+ script_line += next_line[m.end():]
+ continue
+ func_lnum += 1
+ if script_line != func.lines[func_lnum].line:
+ script_is_func = False
+ break
+ script_line = s.lines[script_lnum].line
+
+ if script_is_func:
+ found.append((s, lnum))
+
+ if found:
+ if len(found) > 1:
+ LOGGER.warning(
+ 'Found multiple sources for anonymous function %s (%s).',
+ func.name, (', '.join('%s:%d' % (f[0].path, f[1])
+ for f in found)))
+
+ for s, lnum in found:
+ if lnum in s.mapped_dict_functions:
+ # More likely to happen with merged profiles.
+ LOGGER.debug(
+ 'Found already mapped dict function again (%s:%d).',
+ s.path, lnum)
+ continue
+ s.mapped_dict_functions.add(lnum)
+ return (s, lnum)
+ return found[0]
+
+ def get_anon_func_script_line(self, func):
+ funcname = func.name
+ try:
+ return self.anonymous_functions[funcname]
+ except KeyError:
+ f_info = self._get_anon_func_script_line(func)
+ if f_info is not None:
+ (script, lnum) = f_info
+ self.anonymous_functions[func.name] = (script, lnum)
+ return self.anonymous_functions[funcname]
+
+ def find_func_in_source(self, func):
+ funcname = func.name
+ if funcname.isdigit():
+ # This is an anonymous function, which we need to lookup based on
+ # its source contents.
+ return self.get_anon_func_script_line(func)
+
+ m = re.match(r'^\d+_', funcname)
+ if m:
+ funcname = 's:' + funcname[m.end():]
+
+ found = []
+ for script in self.scripts:
+ try:
+ lnums = script.func_to_lnums[funcname]
+ except KeyError:
+ continue
+
+ for script_lnum in lnums:
+ if self.source_contains_func(script, script_lnum, func):
+ found.append((script, script_lnum))
+ if found:
+ if len(found) > 1:
+ LOGGER.warning('Found multiple sources for function %s (%s).',
+ func, (', '.join('%s:%d' % (f[0].path, f[1])
+ for f in found)))
+ return found[0]
+ return None
+
+ @staticmethod
+ def source_contains_func(script, script_lnum, func):
+ for [f_lnum, f_line] in func.lines.items():
+ s_line = script.lines[script_lnum + f_lnum]
+
+ # XXX: might not be the same, since function lines
+ # are joined, while script lines might be spread
+ # across several lines (prefixed with \).
+ script_source = s_line.line
+ if script_source != f_line.line:
+ while True:
+ # try:
+ peek = script.lines[script_lnum +
+ f_lnum + 1]
+ # except KeyError:
+ # pass
+ if True:
+ m = re.match(RE_CONTINUING_LINE, peek.line)
+ if m:
+ script_source += peek.line[m.end():]
+ script_lnum += 1
+ # script_lines.append(peek)
+ continue
+ if script_source == f_line.line:
+ break
+
+ return False
+ return True
+
+ def parse(self):
+ LOGGER.debug('Parsing file: %s', self.fstr)
+ if self.fobj:
+ return self._parse(self.fobj)
+ with open(self.fname, 'r') as file_object:
+ return self._parse(file_object)
+
+ def _parse(self, file_object):
+ in_script = False
+ in_function = False
+ plnum = lnum = 0
+
+ def skip_to_count_header():
+ skipped = 0
+ while True:
+ next_line = next(file_object)
+ skipped += 1
+ if next_line.startswith('count'):
+ break
+ return skipped
+
+ for line in file_object:
+ plnum += 1
+ line = line.rstrip('\r\n')
+ if line == '':
+ if in_function:
+ func_name = in_function.name
+ script_line = self.find_func_in_source(in_function)
+ if not script_line:
+ LOGGER.error('Could not find source for function: %s',
+ func_name)
+ in_function = False
+ continue
+
+ # Assign counts from function to script.
+ script, script_lnum = script_line
+ for [f_lnum, f_line] in in_function.lines.items():
+ s_line = script.lines[script_lnum + f_lnum]
+
+ # XXX: might not be the same, since function lines
+ # are joined, while script lines might be spread
+ # across several lines (prefixed with \).
+ script_source = s_line.line
+ if script_source != f_line.line:
+ while True:
+ try:
+ peek = script.lines[script_lnum +
+ f_lnum + 1]
+ except KeyError:
+ pass
+ else:
+ m = re.match(RE_CONTINUING_LINE, peek.line)
+ if m:
+ script_source += peek.line[m.end():]
+ script_lnum += 1
+ # script_lines.append(peek)
+ continue
+ if script_source == f_line.line:
+ break
+
+ assert 0, 'Script line matches function line.'
+
+ if f_line.count is not None:
+ if s_line.count:
+ s_line.count += f_line.count
+ else:
+ s_line.count = f_line.count
+ if f_line.self_time:
+ if s_line.self_time:
+ s_line.self_time += f_line.self_time
+ else:
+ s_line.self_time = f_line.self_time
+ if f_line.total_time:
+ if s_line.total_time:
+ s_line.total_time += f_line.total_time
+ else:
+ s_line.total_time = f_line.total_time
+
+ in_script = False
+ in_function = False
+ continue
+
+ if in_script or in_function:
+ lnum += 1
+ try:
+ count, total_time, self_time = parse_count_and_times(line)
+ except Exception as exc:
+ LOGGER.warning(
+ 'Could not parse count/times from line: %s (%s:%d).',
+ line, self.fstr, plnum)
+ continue
+ source_line = line[28:]
+
+ if in_script:
+ in_script.lines[lnum] = Line(
+ line=source_line, count=count,
+ total_time=total_time, self_time=self_time)
+ if count or lnum == 1:
+ # Parse line 1 always, as a workaround for
+ # https://github.com/vim/vim/issues/2103.
+ in_script.parse_script_line(lnum, source_line)
+ else:
+ if count is None:
+ # Functions do not have continued lines, assume 0.
+ count = 0
+ line = Line(line=source_line, count=count,
+ total_time=total_time, self_time=self_time)
+ in_function.lines[lnum] = line
+
+ elif line.startswith('SCRIPT '):
+ fname = line[8:]
+ in_script = Script(fname)
+ LOGGER.debug('Parsing script %s', in_script)
+ self.scripts.append(in_script)
+
+ next_line = next(file_object)
+ m = re.match('Sourced (\d+) time', next_line)
+ in_script.sourced_count = int(m.group(1))
+
+ plnum += skip_to_count_header() + 1
+ lnum = 0
+
+ elif line.startswith('FUNCTION '):
+ func_name = line[10:-2]
+ in_function = Function(name=func_name)
+ LOGGER.debug('Parsing function %s', in_function)
+ plnum += skip_to_count_header()
+ lnum = 0
+
+
+def parse_count_and_times(line):
+ count = line[0:5]
+ if count == '':
+ return 0, None, None
+ if count == ' ':
+ count = None
+ else:
+ count = int(count)
+ total_time = line[8:16]
+ if total_time == ' ':
+ total_time = None
+ else:
+ total_time = float(total_time)
+ self_time = line[19:27]
+ if self_time == ' ':
+ self_time = None
+ else:
+ self_time = float(self_time)
+
+ return count, total_time, self_time
+
+
+def coverage_init(reg, options):
+ """
+ Called from coverage.py when used as plugin in .coveragerc.
+
+ This is not really necessary, but let's keep it so that e.g.
+ `coverage annotate` can be used.
+
+ [run]
+ plugins = covimerage
+ """
+ from .coveragepy import CoveragePlugin
+
+ reg.add_file_tracer(CoveragePlugin())
diff --git a/covimerage/__main__.py b/covimerage/__main__.py
new file mode 100644
index 00000000..5da29c46
--- /dev/null
+++ b/covimerage/__main__.py
@@ -0,0 +1,3 @@
+if __name__ == '__main__':
+ from .cli import main
+ main()
diff --git a/covimerage/__version__.py b/covimerage/__version__.py
new file mode 100644
index 00000000..838f52e6
--- /dev/null
+++ b/covimerage/__version__.py
@@ -0,0 +1 @@
+__version__ = '0.0.1.dev3'
diff --git a/covimerage/_compat.py b/covimerage/_compat.py
new file mode 100644
index 00000000..c6691997
--- /dev/null
+++ b/covimerage/_compat.py
@@ -0,0 +1,28 @@
+try:
+ FileNotFoundError = FileNotFoundError
+except NameError:
+ FileNotFoundError = IOError
+
+try:
+ from StringIO import StringIO
+except ImportError:
+ from io import StringIO # noqa: F401
+
+try:
+ from shlex import quote as shell_quote
+except ImportError:
+ import re
+
+ # Copy'n'paste from Python 3.6.2.
+ _find_unsafe = re.compile(r'[^a-zA-Z0-9_@%+=:,./-]').search
+
+ def shell_quote(s):
+ """Return a shell-escaped version of the string *s*."""
+ if not s:
+ return "''"
+ if _find_unsafe(s) is None:
+ return s
+
+ # use single quotes, and put single quotes into double quotes
+ # the string $'b is then quoted as '$'"'"'b'
+ return "'" + s.replace("'", "'\"'\"'") + "'"
diff --git a/covimerage/cli.py b/covimerage/cli.py
new file mode 100644
index 00000000..65ab12b0
--- /dev/null
+++ b/covimerage/cli.py
@@ -0,0 +1,229 @@
+import os
+
+import click
+
+from . import DEFAULT_COVERAGE_DATA_FILE, MergedProfiles, Profile
+from .__version__ import __version__
+from .coveragepy import CoverageWrapper
+from .logger import LOGGER
+from .utils import build_vim_profile_args, join_argv
+
+
+@click.group(context_settings={'help_option_names': ['-h', '--help']})
+@click.version_option(__version__, '-V', '--version', prog_name='covimerage')
+@click.option('-v', '--verbose', count=True, help='Increase verbosity.')
+@click.option('-q', '--quiet', count=True, help='Decrease verbosity.')
+def main(verbose, quiet):
+ if verbose - quiet:
+ LOGGER.setLevel(max(10, LOGGER.level - (verbose - quiet) * 10))
+
+
+@main.command()
+@click.argument('profile_file', type=click.File('r'), required=False, nargs=-1)
+@click.option('--data-file', required=False, show_default=True,
+ default=DEFAULT_COVERAGE_DATA_FILE, type=click.File(mode='w'))
+@click.option('--source', type=click.types.Path(exists=True), help=(
+ 'Source files/dirs to include. This is necessary to include completely '
+ 'uncovered files.'), show_default=True, multiple=True)
+def write_coverage(profile_file, data_file, source):
+ """
+ Parse PROFILE_FILE (output from Vim's :profile) and write it into DATA_FILE
+ (Coverage.py compatible).
+ """
+ m = MergedProfiles(source=source)
+ m.add_profile_files(*profile_file)
+ if not m.write_coveragepy_data(data_file=data_file):
+ raise click.ClickException('No data to report.')
+
+
+@main.command(context_settings=dict(
+ # ignore_unknown_options=True,
+ allow_interspersed_args=False,
+))
+@click.argument('args', nargs=-1, type=click.UNPROCESSED)
+@click.option('--wrap-profile/--no-wrap-profile', required=False,
+ default=True, show_default=True,
+ help='Wrap VIM cmd with options to create a PROFILE_FILE.')
+@click.option('--profile-file', required=False, metavar='PROFILE_FILE',
+ type=click.Path(dir_okay=False),
+ help='File name for the PROFILE_FILE file. By default a temporary file is used.') # noqa: E501
+@click.option('--data-file', required=False, type=click.File('w'),
+ help='DATA_FILE to write into.', show_default=True)
+@click.option('--append', is_flag=True, default=False,
+ help='Read existing DATA_FILE for appending.', show_default=True)
+@click.option('--write-data/--no-write-data', is_flag=True,
+ default=True, show_default=True,
+ help='Write Coverage.py compatible DATA_FILE.')
+@click.option('--report/--no-report', is_flag=True, default=True,
+ help='Automatically report. This avoids having to write an intermediate data file.') # noqa: E501
+@click.option('--report-file', type=click.File('w'),
+ help='Report output file. Defaults to stdout.')
+# TODO: rather handle this via real options, and pass them through?!
+@click.option('--report-options', required=False,
+ help='Options to be passed on to `covimerage report`.')
+@click.option('--source', type=click.types.Path(exists=True), default=['.'],
+ multiple=True)
+@click.pass_context
+def run(ctx, args, wrap_profile, profile_file, write_data, data_file,
+ report, report_file, report_options, source, append):
+ """
+ Run VIM wrapped with :profile instructions.
+
+ """
+ import subprocess
+ import tempfile
+
+ if report:
+ report_cmd = main.get_command(ctx, 'report')
+ if report_options:
+ report_args = click.parser.split_arg_string(report_options)
+ parser = report_cmd.make_parser(ctx)
+ try:
+ report_opts, _, _ = parser.parse_args(args=report_args)
+ except click.exceptions.UsageError as exc:
+ raise click.exceptions.UsageError(
+ 'Failed to parse --report-options: %s' % exc.message,
+ ctx=ctx)
+ else:
+ report_opts = {}
+
+ args = list(args)
+ if wrap_profile:
+ if not profile_file:
+ # TODO: remove it automatically in the end?
+ profile_file = tempfile.mktemp(prefix='covimerage.profile.')
+ args += build_vim_profile_args(profile_file, source)
+ cmd = args
+ LOGGER.info('Running cmd: %s (in %s)', join_argv(cmd), os.getcwd())
+
+ try:
+ exit_code = subprocess.call(cmd)
+ except Exception as exc:
+ raise click.exceptions.ClickException(
+ 'Failed to run %s: %s' % (cmd, exc))
+
+ if profile_file:
+ if not os.path.exists(profile_file):
+ if not exit_code:
+ exit = click.exceptions.ClickException(
+ 'The profile file (%s) has not been created.' % (
+ profile_file))
+ exit.exit_code = 1
+ raise exit
+
+ elif write_data or report:
+ LOGGER.info('Parsing profile file %s.', profile_file)
+ p = Profile(profile_file)
+ p.parse()
+
+ if (write_data or append) and not data_file:
+ data_file = DEFAULT_COVERAGE_DATA_FILE
+
+ if append:
+ m = MergedProfiles([p], source=source, append_to=data_file)
+ else:
+ m = MergedProfiles([p], source=source)
+
+ if write_data:
+ m.write_coveragepy_data(data_file)
+
+ if report:
+ cov_data = m.get_coveragepy_data()
+ if not cov_data:
+ raise click.ClickException('No data to report.')
+ report_opts['data'] = cov_data
+ ctx.invoke(report_cmd, report_file=report_file,
+ **report_opts)
+
+ if exit_code != 0:
+ exit = click.exceptions.ClickException(
+ 'Command exited non-zero: %d.' % exit_code)
+ exit.exit_code = exit_code
+ raise exit
+
+
+def report_data_file_cb(ctx, param, value):
+ """Use click.File for data_file only if it is used, to prevent an error
+ if it does not exist (click tries to open it always)."""
+ if ctx.params.get('profile_file', ()):
+ return value
+ return click.File('r').convert(value, param, ctx)
+
+
+def report_source_cb(ctx, param, value):
+ if value and not ctx.params.get('profile_file', ()):
+ raise click.exceptions.UsageError(
+ '--source can only be used with PROFILE_FILE.')
+ return value
+
+
+@main.command()
+@click.argument('profile_file', type=click.File('r'), required=False, nargs=-1,
+ is_eager=True)
+@click.option('--data-file', required=False, show_default=True, help=(
+ 'DATA_FILE to use in case PROFILE_FILE is not provided.'),
+ callback=report_data_file_cb,
+ default=DEFAULT_COVERAGE_DATA_FILE)
+@click.option('--show-missing', '-m', is_flag=True, default=False, help=(
+ 'Show line numbers of statements in each file that was not executed.'))
+@click.option('--include', required=False, multiple=True, help=(
+ 'Include only files whose paths match one of these patterns. '
+ 'Accepts shell-style wildcards, which must be quoted.'))
+@click.option('--omit', required=False, multiple=True, help=(
+ 'Omit files whose paths match one of these patterns. '
+ 'Accepts shell-style wildcards, which must be quoted.'))
+@click.option('--skip-covered', is_flag=True, default=False,
+ help='Skip files with 100% coverage.')
+@click.option('--source', type=click.types.Path(exists=True), help=(
+ 'Source dirs/files to include (only when PROFILE_FILE is used - otherwise '
+ 'it is expected to be in the data already).'),
+ callback=report_source_cb,
+ show_default=True, default=None, multiple=True)
+@click.option('--report-file', type=click.File('w'),
+ help='Report output file. Defaults to stdout.')
+def report(profile_file, data_file, show_missing, include, omit, skip_covered,
+ source, report_file, data=None):
+ """
+ A wrapper around `coverage report`.
+
+ This will automatically add covimerage as a plugin, and then just forwards
+ most options.
+
+ If PROFILE_FILE is provided this gets parsed, otherwise DATA_FILE is
+ being used.
+ """
+ if data:
+ data_file = None
+ elif profile_file:
+ data_file = None
+ m = MergedProfiles(source=source)
+ m.add_profile_files(*profile_file)
+ data = m.get_coveragepy_data()
+ CoverageWrapper(data=data, data_file=data_file).report(
+ report_file=report_file,
+ show_missing=show_missing, include=include, omit=omit,
+ skip_covered=skip_covered)
+
+
+# TODO: support / pass through --rcfile?!
+@main.command()
+@click.option('--data-file', required=False, type=click.File('r'),
+ default=DEFAULT_COVERAGE_DATA_FILE, show_default=True)
+@click.option('--include', required=False, multiple=True, help=(
+ 'Include only files whose paths match one of these patterns. '
+ 'Accepts shell-style wildcards, which must be quoted.'))
+@click.option('--omit', required=False, multiple=True, help=(
+ 'Omit files whose paths match one of these patterns. '
+ 'Accepts shell-style wildcards, which must be quoted.'))
+@click.option('--ignore-errors', is_flag=True, default=False,
+ show_default=True, required=False,
+ help='Ignore errors while reading source files.')
+def xml(data_file, include, omit, ignore_errors):
+ """
+ A wrapper around `coverage xml`.
+
+ This will automatically add covimerage as a plugin, and then just forwards
+ most options.
+ """
+ CoverageWrapper(data_file=data_file).reportxml(
+ include=include, omit=omit, ignore_errors=ignore_errors)
diff --git a/covimerage/coveragepy.py b/covimerage/coveragepy.py
new file mode 100644
index 00000000..b6d48a90
--- /dev/null
+++ b/covimerage/coveragepy.py
@@ -0,0 +1,175 @@
+import re
+
+import attr
+import click
+import coverage
+
+from ._compat import FileNotFoundError
+from .logger import LOGGER
+from .utils import get_fname_and_fobj_and_str, is_executable_line
+
+RE_EXCLUDED = re.compile(
+ r'"\s*(pragma|PRAGMA)[:\s]?\s*(no|NO)\s*(cover|COVER)')
+
+
+class CoverageWrapperException(click.ClickException):
+ """Inherit from ClickException for automatic handling."""
+ def __init__(self, message, orig_exc=None):
+ self.orig_exc = orig_exc
+ super(CoverageWrapperException, self).__init__(message)
+
+ def format_message(self):
+ """Append information about original exception if any."""
+ msg = super(CoverageWrapperException, self).format_message()
+ if self.orig_exc:
+ return '%s (%r)' % (msg, self.orig_exc)
+ return msg
+
+ def __str__(self):
+ return self.format_message()
+
+ def __repr__(self):
+ return 'CoverageWrapperException(message=%r, orig_exc=%r)' % (
+ self.message, self.orig_exc)
+
+
+@attr.s(frozen=True)
+class CoverageData(object):
+ cov_data = attr.ib(default=None)
+ data_file = attr.ib(default=None)
+
+ def __attrs_post_init__(self):
+ if self.cov_data and self.data_file:
+ raise TypeError('data and data_file are mutually exclusive.')
+ if self.cov_data:
+ if not isinstance(self.cov_data, coverage.data.CoverageData):
+ raise TypeError(
+ 'data needs to be of type coverage.data.CoverageData')
+ return
+ cov_data = coverage.data.CoverageData()
+ if self.data_file:
+ fname, fobj, fstr = get_fname_and_fobj_and_str(self.data_file)
+ try:
+ if fobj:
+ cov_data.read_fileobj(fobj)
+ else:
+ cov_data.read_file(fname)
+ except coverage.CoverageException as exc:
+ raise CoverageWrapperException(
+ 'Coverage could not read data_file: %s' % fstr,
+ orig_exc=exc)
+ object.__setattr__(self, 'cov_data', cov_data)
+
+ @property
+ def lines(self):
+ data = self.cov_data
+ return {f: sorted(data.lines(f)) for f in data.measured_files()}
+
+ def add_lines(self, lines):
+ self.cov_data.add_lines(lines)
+
+
+@attr.s(frozen=True)
+class CoverageWrapper(object):
+ """Wrap Coveragepy related functionality."""
+ data = attr.ib(default=None)
+ data_file = attr.ib(default=None)
+
+ _cached_cov_obj = None
+
+ def __attrs_post_init__(self):
+ if not isinstance(self.data, CoverageData):
+ data = CoverageData(cov_data=self.data, data_file=self.data_file)
+ object.__setattr__(self, 'data', data)
+ elif self.data_file:
+ raise TypeError('data and data_file are mutually exclusive.')
+
+ @property
+ def lines(self):
+ return self.data.lines
+
+ @property
+ def _cov_obj(self):
+ if not self._cached_cov_obj:
+ object.__setattr__(self, '_cached_cov_obj', self._get_cov_obj())
+ return self._cached_cov_obj
+
+ def _get_cov_obj(self):
+ class CoverageW(coverage.Coverage):
+ """Wrap/shortcut _get_file_reporter to return ours."""
+ def _get_file_reporter(self, morf):
+ return FileReporter(morf)
+
+ cov_coverage = CoverageW(config_file=False)
+ cov_coverage._init()
+ cov_coverage.data = self.data.cov_data
+ return cov_coverage
+
+ def report(self, report_file=None, show_missing=None,
+ include=None, omit=None, skip_covered=None):
+ self._cov_obj.report(
+ file=report_file, show_missing=show_missing, include=include,
+ omit=omit, skip_covered=skip_covered)
+
+ def reportxml(self, report_file=None, include=None, omit=None,
+ ignore_errors=None):
+ self._cov_obj.xml_report(
+ outfile=report_file, include=include, omit=omit,
+ ignore_errors=ignore_errors)
+
+
+class FileReporter(coverage.FileReporter):
+ _split_lines = None
+
+ def __repr__(self):
+ return ''.format(self.filename)
+
+ def source(self):
+ try:
+ with open(self.filename, 'rb') as f:
+ source = f.read()
+ try:
+ return source.decode('utf8')
+ except UnicodeDecodeError:
+ LOGGER.debug('UnicodeDecodeError in %s for utf8. '
+ 'Trying iso-8859-15.', self.filename)
+ return source.decode('iso-8859-15')
+ except FileNotFoundError as exc:
+ LOGGER.warning('%s', exc)
+ raise coverage.misc.NoSource(str(exc))
+ except Exception as exc:
+ raise CoverageWrapperException(
+ 'Could not read source for %s.' % self.filename, orig_exc=exc)
+
+ @property
+ def split_lines(self):
+ if self._split_lines is None:
+ self._split_lines = self.source().splitlines()
+ return self._split_lines
+
+ # NOTE: should be done before already, to end up in .coverage already.
+ # def translate_lines(self, lines):
+ # if (1 not in lines and
+ # lines and re.match(RE_FUNC_PREFIX, self.split_lines[0])):
+ # lines.append(1)
+ # return set(lines)
+
+ def lines(self):
+ lines = []
+ for lnum, l in enumerate(self.split_lines, start=1):
+ if is_executable_line(l):
+ lines.append(lnum)
+ return set(lines)
+
+ def excluded_lines(self):
+ lines = []
+ for lnum, l in enumerate(self.split_lines, start=1):
+ # TODO: exclude until end of block (by using vimlparser?!)
+ if RE_EXCLUDED.search(l):
+ lines.append(lnum)
+ return set(lines)
+
+
+class CoveragePlugin(coverage.CoveragePlugin):
+ def file_reporter(self, filename):
+ return FileReporter(filename)
diff --git a/covimerage/logger.py b/covimerage/logger.py
new file mode 100644
index 00000000..8c74ea01
--- /dev/null
+++ b/covimerage/logger.py
@@ -0,0 +1,21 @@
+import logging
+import sys
+
+LOGGER = logging.getLogger('covimerage')
+LOGGER.setLevel(logging.INFO)
+
+
+class AlwaysStderrHandler(logging.StreamHandler):
+ def __init__(self, level=logging.NOTSET):
+ logging.Handler.__init__(self, level)
+
+ @property
+ def stream(self):
+ return sys.stderr
+
+ def handleError(self, record):
+ super(AlwaysStderrHandler, self).handleError(record)
+ raise Exception('Internal logging error')
+
+
+LOGGER.addHandler(AlwaysStderrHandler())
diff --git a/covimerage/utils.py b/covimerage/utils.py
new file mode 100644
index 00000000..1818d185
--- /dev/null
+++ b/covimerage/utils.py
@@ -0,0 +1,58 @@
+import os
+import re
+
+from click.utils import string_types
+
+from ._compat import shell_quote
+
+# Empty (whitespace only), comments, continued, or `end` statements.
+RE_NON_EXECED = re.compile(r'^\s*("|\\|end|$)')
+
+
+def get_fname_and_fobj_and_str(fname_or_fobj):
+ if isinstance(fname_or_fobj, string_types):
+ return fname_or_fobj, None, fname_or_fobj
+ try:
+ fname = fname_or_fobj.name
+ except AttributeError:
+ return None, fname_or_fobj, str(fname_or_fobj)
+ return fname, fname_or_fobj, fname
+
+
+def build_vim_profile_args(profile_fname, sources):
+ args = ['--cmd', 'profile start %s' % profile_fname]
+ for source in sources:
+ if os.path.isdir(source):
+ pattern = '%s/*'
+ else:
+ pattern = '%s'
+ args += ['--cmd', 'profile! file %s' % (pattern % source)]
+ return args
+
+
+def is_executable_filename(filename):
+ # We're only interested in files that look like reasonable Vim
+ # files:
+ # Must end with .vim or start with vim, and must not have
+ # certain funny characters that probably mean they are editor
+ # junk.
+ if re.match(r'[^.][^#~!$@%^&*()+=,]*\.n?vim$', filename):
+ return True
+ if re.match(r'[^#~!$@%^&*()+=,]*vimrc[^#~!$@%^&*()+=,]*$', filename):
+ return True
+ return False
+
+
+def find_executable_files(src_dir):
+ for i, (dirpath, dirnames, filenames) in enumerate(os.walk(src_dir)):
+ for filename in filenames:
+ if is_executable_filename(filename):
+ yield os.path.join(dirpath, filename)
+
+
+def is_executable_line(l):
+ return not RE_NON_EXECED.match(l)
+
+
+def join_argv(argv):
+ return ' '.join(shell_quote(s) for s in argv)
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 00000000..74193818
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,14 @@
+[tool:pytest]
+testpaths = tests
+addopts = -ra
+
+[isort]
+combine_as_imports = 1
+default_section = THIRDPARTY
+force_sort_within_sections = 1
+include_trailing_comma = 1
+known_first_party = covimerage
+lines = 79
+multi_line_output = 5
+order_by_type = 1
+use_parentheses = 1
diff --git a/setup.py b/setup.py
new file mode 100755
index 00000000..3486f983
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Based on https://github.com/kennethreitz/setup.py.
+
+# Note: To use the 'upload' functionality of this file, you must:
+# $ pip install twine
+
+# import io
+import os
+from shutil import rmtree
+import sys
+
+from setuptools import Command, setup
+
+# Package meta-data.
+NAME = 'covimerage'
+DESCRIPTION = 'Generate coverage information for Vim scripts.'
+URL = 'https://github.com/Vimjas/covimerage'
+# EMAIL = 'me@example.com'
+AUTHOR = 'Daniel Hahler'
+
+# What packages are required for this module to be executed?
+REQUIRED = ['attrs', 'click', 'coverage']
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+# Import the README and use it as the long-description.
+# Note: this will only work if 'README.rst' is present in your MANIFEST.in
+# file!
+# with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f:
+# long_description = '\n' + f.read()
+
+# Load the package's __version__.py module as a dictionary.
+about = {}
+with open(os.path.join(here, NAME, '__version__.py')) as f:
+ exec(f.read(), about)
+
+
+class PublishCommand(Command):
+ """Support setup.py publish."""
+
+ description = 'Build and publish the package.'
+ user_options = []
+
+ @staticmethod
+ def status(s):
+ """Prints things in bold."""
+ print('\033[1m{0}\033[0m'.format(s))
+
+ def initialize_options(self):
+ pass
+
+ def finalize_options(self):
+ pass
+
+ def run(self):
+ try:
+ self.status('Removing previous builds…')
+ rmtree(os.path.join(here, 'dist'))
+ except OSError:
+ pass
+
+ self.status('Building Source and Wheel (universal) distribution…')
+ os.system('{0} setup.py sdist bdist_wheel --universal'.format(
+ sys.executable))
+
+ self.status('Uploading the package to PyPi via Twine…')
+ os.system('twine upload dist/*')
+
+ sys.exit()
+
+
+DEPS_QA = [
+ 'flake8',
+ 'flake8-isort',
+ 'flake8-quotes',
+]
+DEPS_TESTING = [
+ 'pytest',
+ 'pytest-catchlog',
+ 'pytest-cov',
+ 'pytest-mock',
+]
+
+# Where the magic happens:
+setup(
+ name=NAME,
+ version=about['__version__'],
+ description=DESCRIPTION,
+ # long_description=long_description,
+ author=AUTHOR,
+ # author_email=EMAIL,
+ url=URL,
+ packages=['covimerage'],
+ entry_points={
+ 'console_scripts': ['covimerage=covimerage.cli:main'],
+ },
+ install_requires=REQUIRED,
+ extras_require={
+ 'testing': DEPS_TESTING,
+ 'dev': DEPS_TESTING + DEPS_QA + [
+ 'pdbpp',
+ 'pytest-pdb',
+ ],
+ 'qa': DEPS_QA,
+ },
+ include_package_data=True,
+ license='MIT',
+ classifiers=[
+ # Trove classifiers
+ # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
+ 'License :: OSI Approved :: MIT License',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Programming Language :: Python :: Implementation :: CPython',
+ 'Programming Language :: Python :: Implementation :: PyPy'
+ ],
+ # $ setup.py publish support.
+ cmdclass={
+ 'publish': PublishCommand,
+ },
+)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..5fcd6767
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,38 @@
+from glob import glob
+import os
+
+from click.testing import CliRunner
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def ensure_no_coverage_data_changed_in_cwd():
+ fnames = glob('.coverage')
+ old_coverage = {
+ fname: os.stat(fname) if os.path.exists(fname) else None
+ for fname in fnames}
+ yield
+ new_fnames = glob('.coverage')
+ for new_fname in new_fnames: # pragma: no cover
+ old_stat = old_coverage.get(new_fname)
+ if old_stat is None:
+ pytest.fail('Test created a data file: %s.' % new_fname)
+ elif old_stat != os.stat(new_fname):
+ pytest.fail('Test changed an existing data file: %s.' % (
+ new_fname))
+
+
+@pytest.fixture
+def runner():
+ return CliRunner()
+
+
+@pytest.fixture
+def devnull():
+ with open(os.devnull) as f:
+ yield f
+
+
+@pytest.fixture
+def covdata_empty():
+ return "!coverage.py: This is a private format, don't read it directly!{}"
diff --git a/tests/fixtures/conditional_function.profile b/tests/fixtures/conditional_function.profile
new file mode 100644
index 00000000..36bb27da
--- /dev/null
+++ b/tests/fixtures/conditional_function.profile
@@ -0,0 +1,56 @@
+SCRIPT tests/test_plugin/conditional_function.vim
+Sourced 1 time
+Total time: 0.000094
+ Self time: 0.000074
+
+count total (s) self (s)
+ " Test for detection of conditional functions.
+
+ 1 0.000007 if 0
+ function Foo()
+ return 1
+ endfunction
+ else
+ 1 0.000002 function Foo()
+ return 1
+ endfunction
+ 1 0.000001 endif
+
+ 1 0.000013 0.000008 if Foo()
+ 1 0.000002 function Bar()
+ echom 1
+ endfunction
+ 1 0.000001 else
+ function Bar()
+ echom 1
+ endfunction
+ endif
+
+ 1 0.000021 0.000007 call Bar()
+
+FUNCTION Foo()
+Called 1 time
+Total time: 0.000005
+ Self time: 0.000005
+
+count total (s) self (s)
+ 1 0.000003 return 1
+
+FUNCTION Bar()
+Called 1 time
+Total time: 0.000015
+ Self time: 0.000015
+
+count total (s) self (s)
+ 1 0.000014 echom 1
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000015 Bar()
+ 1 0.000005 Foo()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000015 Bar()
+ 1 0.000005 Foo()
+
diff --git a/tests/fixtures/continued_lines.profile b/tests/fixtures/continued_lines.profile
new file mode 100644
index 00000000..c178d664
--- /dev/null
+++ b/tests/fixtures/continued_lines.profile
@@ -0,0 +1,11 @@
+SCRIPT /test_plugin/continued_lines.vim
+Sourced 1 time
+Total time: 0.000071
+ Self time: 0.000071
+
+count total (s) self (s)
+ echom 1
+ 1 0.000028 echom 2
+ \ 3
+ 1 0.000013 echom 4
+
diff --git a/tests/fixtures/dict_function.profile b/tests/fixtures/dict_function.profile
new file mode 100644
index 00000000..655cb852
--- /dev/null
+++ b/tests/fixtures/dict_function.profile
@@ -0,0 +1,40 @@
+SCRIPT /test_plugin/dict_function.vim
+Sourced 1 time
+Total time: 0.000159
+ Self time: 0.000066
+
+count total (s) self (s)
+ " Test parsing of dict function.
+ 1 0.000012 let obj = {}
+ 1 0.000006 function! obj.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+ endfunction
+
+ 1 0.000049 0.000012 call obj.dict_function(0)
+ 1 0.000042 0.000010 call obj.dict_function(1)
+ 1 0.000034 0.000010 call obj.dict_function(2)
+
+FUNCTION 4()
+Called 3 times
+Total time: 0.000093
+ Self time: 0.000093
+
+count total (s) self (s)
+ 3 0.000014 if a:arg
+ 2 0.000032 echom a:arg
+ 2 0.000004 else
+ 1 0.000023 echom a:arg
+ 1 0.000002 endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 3 0.000093 4()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 3 0.000093 4()
+
diff --git a/tests/fixtures/dict_function_used_twice.profile b/tests/fixtures/dict_function_used_twice.profile
new file mode 100644
index 00000000..31bd2c8e
--- /dev/null
+++ b/tests/fixtures/dict_function_used_twice.profile
@@ -0,0 +1,71 @@
+SCRIPT /test_plugin/dict_function_used_twice.vim
+Sourced 1 time
+Total time: 0.000174
+ Self time: 0.000088
+
+count total (s) self (s)
+ " Test parsing of dict function that gets used twice.
+ 1 0.000015 let base = {}
+ 1 0.000006 function! base.base_function() abort dict
+ if 1
+ echom 1
+ else
+ echom 2
+ endif
+ endfunction
+
+ 1 0.000009 let obj1 = deepcopy(base)
+ 1 0.000003 function! obj1.func() abort
+ call self.base_function()
+ endfunction
+
+ 1 0.000004 let obj2 = deepcopy(base)
+ 1 0.000001 function! obj2.func() abort
+ call self.base_function()
+ endfunction
+
+ 1 0.000034 0.000007 call obj1.func()
+ 1 0.000039 0.000006 call obj2.func()
+ 1 0.000019 0.000006 call obj1.base_function()
+ 1 0.000019 0.000007 call obj2.base_function()
+
+FUNCTION 1()
+Called 4 times
+Total time: 0.000070
+ Self time: 0.000070
+
+count total (s) self (s)
+ 4 0.000007 if 1
+ 4 0.000042 echom 1
+ 4 0.000006 else
+ echom 2
+ endif
+
+FUNCTION 2()
+Called 1 time
+Total time: 0.000028
+ Self time: 0.000009
+
+count total (s) self (s)
+ 1 0.000026 0.000008 call self.base_function()
+
+FUNCTION 3()
+Called 1 time
+Total time: 0.000033
+ Self time: 0.000007
+
+count total (s) self (s)
+ 1 0.000032 0.000007 call self.base_function()
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 4 0.000070 1()
+ 1 0.000033 0.000007 3()
+ 1 0.000028 0.000009 2()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 4 0.000070 1()
+ 1 0.000028 0.000009 2()
+ 1 0.000033 0.000007 3()
+
diff --git a/tests/fixtures/dict_function_with_continued_lines.profile b/tests/fixtures/dict_function_with_continued_lines.profile
new file mode 100644
index 00000000..e35e1e75
--- /dev/null
+++ b/tests/fixtures/dict_function_with_continued_lines.profile
@@ -0,0 +1,41 @@
+SCRIPT /test_plugin/dict_function_with_continued_lines.vim
+Sourced 1 time
+Total time: 0.000158
+ Self time: 0.000078
+
+count total (s) self (s)
+ " Test parsing of dict function with continued lines.
+ 1 0.000018 let obj = {}
+ 1 0.000007 function! obj.dict_function(arg) abort
+ if a:arg
+ echom
+ \ a:arg
+ else
+ echom a:arg
+ endif
+ endfunction
+
+ 1 0.000044 0.000013 call obj.dict_function(0)
+ 1 0.000040 0.000008 call obj.dict_function(1)
+ 1 0.000025 0.000008 call obj.dict_function(1)
+
+FUNCTION 1()
+Called 3 times
+Total time: 0.000079
+ Self time: 0.000079
+
+count total (s) self (s)
+ 3 0.000010 if a:arg
+ 2 0.000031 echom a:arg
+ 2 0.000004 else
+ 1 0.000017 echom a:arg
+ 1 0.000002 endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 3 0.000079 1()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 3 0.000079 1()
+
diff --git a/tests/fixtures/dict_function_with_same_source.profile b/tests/fixtures/dict_function_with_same_source.profile
new file mode 100644
index 00000000..e85f5680
--- /dev/null
+++ b/tests/fixtures/dict_function_with_same_source.profile
@@ -0,0 +1,64 @@
+SCRIPT /test_plugin/dict_function_with_same_source.vim
+Sourced 1 time
+Total time: 0.000153
+ Self time: 0.000080
+
+count total (s) self (s)
+ " Test parsing of dict function (with same source).
+ 1 0.000015 let obj1 = {}
+ 1 0.000007 function! obj1.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+ endfunction
+
+ 1 0.000002 let obj2 = {}
+ 1 0.000002 function! obj2.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+ endfunction
+
+ 1 0.000032 0.000009 call obj2.dict_function(0)
+ 1 0.000027 0.000005 call obj2.dict_function(1)
+ 1 0.000021 0.000007 call obj2.dict_function(2)
+ 1 0.000023 0.000008 call obj1.dict_function(3)
+
+FUNCTION 1()
+Called 1 time
+Total time: 0.000014
+ Self time: 0.000014
+
+count total (s) self (s)
+ 1 0.000002 if a:arg
+ 1 0.000008 echom a:arg
+ 1 0.000001 else
+ echom a:arg
+ endif
+
+FUNCTION 2()
+Called 3 times
+Total time: 0.000058
+ Self time: 0.000058
+
+count total (s) self (s)
+ 3 0.000006 if a:arg
+ 2 0.000021 echom a:arg
+ 2 0.000003 else
+ 1 0.000014 echom a:arg
+ 1 0.000001 endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 3 0.000058 2()
+ 1 0.000014 1()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 3 0.000058 2()
+ 1 0.000014 1()
+
diff --git a/tests/fixtures/merge-1.profile b/tests/fixtures/merge-1.profile
new file mode 100644
index 00000000..a2743055
--- /dev/null
+++ b/tests/fixtures/merge-1.profile
@@ -0,0 +1,33 @@
+SCRIPT /test_plugin/merged_profiles.vim
+Sourced 1 time
+Total time: 0.000000
+ Self time: 0.000000
+
+count total (s) self (s)
+ " Generate profile output for merged profiles.
+ " Used merged_profiles-init.vim
+ 1 0.000005 if !exists('s:conditional')
+ 1 0.000002 function! F()
+ echom 1
+ endfunction
+ 1 0.000003 let s:conditional = 1
+ 1 0.000020 echom 1
+ 1 0.000020 0.000006 call F()
+ call NeomakeTestsProfileRestart()
+
+FUNCTION F()
+Called 1 time
+Total time: 0.000014
+ Self time: 0.000014
+
+count total (s) self (s)
+ 1 0.000013 echom 1
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000014 F()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000014 F()
+
diff --git a/tests/fixtures/merge-2.profile b/tests/fixtures/merge-2.profile
new file mode 100644
index 00000000..87580505
--- /dev/null
+++ b/tests/fixtures/merge-2.profile
@@ -0,0 +1,42 @@
+SCRIPT /test_plugin/merged_profiles.vim
+Sourced 1 time
+Total time: 76887.125215
+ Self time: 76887.125183
+
+count total (s) self (s)
+ " Generate profile output for merged profiles.
+ " Used merged_profiles-init.vim
+ 1 0.000009 if !exists('s:conditional')
+ function! F()
+ echom 1
+ endfunction
+ let s:conditional = 1
+ echom 1
+ call F()
+ call NeomakeTestsProfileRestart()
+ exe 'source ' . expand('')
+ 1 0.000001 else
+ 1 0.000002 function! F()
+ echom 2
+ endfunction
+ 1 0.000024 echom 2
+ 1 0.000030 0.000014 call F()
+ 1 0.000002 endif
+
+
+FUNCTION F()
+Called 1 time
+Total time: 0.000016
+ Self time: 0.000016
+
+count total (s) self (s)
+ 1 0.000011 echom 2
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000016 F()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000016 F()
+
diff --git a/tests/fixtures/merged_conditionals-0.profile b/tests/fixtures/merged_conditionals-0.profile
new file mode 100644
index 00000000..dd2e08b1
--- /dev/null
+++ b/tests/fixtures/merged_conditionals-0.profile
@@ -0,0 +1,57 @@
+SCRIPT tests/test_plugin/merged_conditionals.vim
+Sourced 1 time
+Total time: 0.000084
+ Self time: 0.000070
+
+count total (s) self (s)
+ " Generate profile output for merged profiles.
+ 1 0.000025 let cond = get(g:, 'test_conditional', 0)
+
+ 1 0.000004 if cond == 1
+ let foo = 1
+ elseif cond == 2
+ let foo = 2
+ elseif cond == 3
+ let foo = 3
+ else
+ 1 0.000003 let foo = 'else'
+ 1 0.000001 endif
+
+ 1 0.000003 function F(...)
+ if a:1 == 1
+ let foo = 1
+ elseif a:1 == 2
+ let foo = 2
+ elseif a:1 == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+ endfunction
+
+ 1 0.000024 0.000010 call F(cond)
+
+FUNCTION F()
+Called 1 time
+Total time: 0.000014
+ Self time: 0.000014
+
+count total (s) self (s)
+ 1 0.000002 if a:1 == 1
+ let foo = 1
+ elseif a:1 == 2
+ let foo = 2
+ elseif a:1 == 3
+ let foo = 3
+ else
+ 1 0.000002 let foo = 'else'
+ 1 0.000001 endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000014 F()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000014 F()
+
diff --git a/tests/fixtures/merged_conditionals-1.profile b/tests/fixtures/merged_conditionals-1.profile
new file mode 100644
index 00000000..79820f4e
--- /dev/null
+++ b/tests/fixtures/merged_conditionals-1.profile
@@ -0,0 +1,57 @@
+SCRIPT tests/test_plugin/merged_conditionals.vim
+Sourced 1 time
+Total time: 0.000113
+ Self time: 0.000091
+
+count total (s) self (s)
+ " Generate profile output for merged profiles.
+ 1 0.000029 let cond = get(g:, 'test_conditional', 0)
+
+ 1 0.000006 if cond == 1
+ 1 0.000004 let foo = 1
+ 1 0.000002 elseif cond == 2
+ let foo = 2
+ elseif cond == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+
+ 1 0.000005 function F(...)
+ if a:1 == 1
+ let foo = 1
+ elseif a:1 == 2
+ let foo = 2
+ elseif a:1 == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+ endfunction
+
+ 1 0.000035 0.000013 call F(cond)
+
+FUNCTION F()
+Called 1 time
+Total time: 0.000022
+ Self time: 0.000022
+
+count total (s) self (s)
+ 1 0.000005 if a:1 == 1
+ 1 0.000004 let foo = 1
+ 1 0.000002 elseif a:1 == 2
+ let foo = 2
+ elseif a:1 == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000022 F()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000022 F()
+
diff --git a/tests/fixtures/merged_conditionals-2.profile b/tests/fixtures/merged_conditionals-2.profile
new file mode 100644
index 00000000..0e1b9b0d
--- /dev/null
+++ b/tests/fixtures/merged_conditionals-2.profile
@@ -0,0 +1,57 @@
+SCRIPT tests/test_plugin/merged_conditionals.vim
+Sourced 1 time
+Total time: 0.000098
+ Self time: 0.000078
+
+count total (s) self (s)
+ " Generate profile output for merged profiles.
+ 1 0.000017 let cond = get(g:, 'test_conditional', 0)
+
+ 1 0.000003 if cond == 1
+ let foo = 1
+ elseif cond == 2
+ 1 0.000002 let foo = 2
+ 1 0.000001 elseif cond == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+
+ 1 0.000007 function F(...)
+ if a:1 == 1
+ let foo = 1
+ elseif a:1 == 2
+ let foo = 2
+ elseif a:1 == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+ endfunction
+
+ 1 0.000031 0.000011 call F(cond)
+
+FUNCTION F()
+Called 1 time
+Total time: 0.000020
+ Self time: 0.000020
+
+count total (s) self (s)
+ 1 0.000003 if a:1 == 1
+ let foo = 1
+ elseif a:1 == 2
+ 1 0.000003 let foo = 2
+ 1 0.000002 elseif a:1 == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000020 F()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000020 F()
+
diff --git a/tests/fixtures/test_plugin.profile b/tests/fixtures/test_plugin.profile
new file mode 100644
index 00000000..b3908556
--- /dev/null
+++ b/tests/fixtures/test_plugin.profile
@@ -0,0 +1,41 @@
+SCRIPT /test_plugin/autoload/test_plugin.vim
+Sourced 1 time
+Total time: 0.000033
+ Self time: 0.000033
+
+count total (s) self (s)
+ function! test_plugin#func1(a) abort
+ echom 'func1'
+ endfunction
+
+ 1 0.000005 function! test_plugin#func2(a) abort
+ echom 'func2'
+ endfunction
+
+
+FUNCTION test_plugin#func2()
+Called 0 times
+Total time: 0.000000
+ Self time: 0.000000
+
+count total (s) self (s)
+ echom 'func2'
+
+FUNCTION test_plugin#func1()
+Called 1 time
+Total time: 0.000037
+ Self time: 0.000037
+
+count total (s) self (s)
+ 1 0.000035 echom 'func1'
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 1 0.000037 test_plugin#func1()
+ test_plugin#func2()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 1 0.000037 test_plugin#func1()
+ test_plugin#func2()
+
diff --git a/tests/fixtures/test_plugin.vim.profile b/tests/fixtures/test_plugin.vim.profile
new file mode 100644
index 00000000..1ca5bd2a
--- /dev/null
+++ b/tests/fixtures/test_plugin.vim.profile
@@ -0,0 +1,40 @@
+SCRIPT /home/daniel/src/covimerage/test_plugin/autoload/test_plugin.vim
+Sourced 2 times
+Total time: 0.000128
+ Self time: 0.000050
+
+count total (s) self (s)
+ " Test parsing of dict function.
+ 2 0.000007 let obj = {}
+ 2 0.000004 function! obj.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+ endfunction
+
+ 2 0.000049 0.000013 call obj.dict_function(0)
+ 2 0.000026 0.000004 call obj.dict_function(1)
+ 2 0.000026 0.000006 call obj.dict_function(2)
+
+FUNCTION 2()
+Called 3 times
+Total time: 0.000027
+ Self time: 0.000027
+
+count total (s) self (s)
+ 3 0.000002 if a:arg
+ 2 0.000008 echom a:arg
+ 2 0.000001 else
+ 1 0.000008 echom a:arg
+ 1 0.000001 endif
+
+FUNCTIONS SORTED ON TOTAL TIME
+count total (s) self (s) function
+ 3 0.000027 2()
+
+FUNCTIONS SORTED ON SELF TIME
+count total (s) self (s) function
+ 3 0.000027 2()
+
diff --git a/tests/t.vim b/tests/t.vim
new file mode 100644
index 00000000..ad3ba426
--- /dev/null
+++ b/tests/t.vim
@@ -0,0 +1,17 @@
+let prof_fname = get(g:, 'prof_fname')
+exe 'profile start '.prof_fname
+profile! file tests/test_plugin/**
+
+set runtimepath+=$PWD/test_plugin
+
+" call test_plugin#func1(1)
+
+" autocmd SourceCmd * echom 'SOURCE: '.expand('')
+" function! s:dump_anonymous_functions()
+" redir => output
+"
+" redir END
+" endfunction
+" autocmd VimLeave *
+"
+" augroup END
diff --git a/tests/test__compat.py b/tests/test__compat.py
new file mode 100644
index 00000000..e9d01e31
--- /dev/null
+++ b/tests/test__compat.py
@@ -0,0 +1,6 @@
+def test_shell_quote():
+ from covimerage._compat import shell_quote
+
+ assert shell_quote('') == "''"
+ assert shell_quote('word') == 'word'
+ assert shell_quote('two words') == "'two words'"
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 00000000..efa1dce6
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,605 @@
+try:
+ from StringIO import StringIO
+except ImportError:
+ from io import StringIO
+import os
+from subprocess import call
+import sys
+
+import pytest
+
+from covimerage import DEFAULT_COVERAGE_DATA_FILE, cli
+from covimerage.__version__ import __version__
+
+
+def test_dunder_main_run(capfd):
+ assert call([sys.executable, '-m', 'covimerage']) == 0
+ out, err = capfd.readouterr()
+ assert out.startswith('Usage: __main__')
+
+
+def test_dunder_main_run_help(capfd):
+ assert call([sys.executable, '-m', 'covimerage', '--version']) == 0
+ out, err = capfd.readouterr()
+ assert out == 'covimerage, version %s\n' % __version__
+
+
+def test_cli(runner, tmpdir):
+ with tmpdir.as_cwd() as old_dir:
+ with pytest.raises(SystemExit) as excinfo:
+ cli.write_coverage([os.path.join(
+ str(old_dir), 'tests/fixtures/conditional_function.profile')])
+ assert excinfo.value.code == 0
+ assert os.path.exists(DEFAULT_COVERAGE_DATA_FILE)
+
+ result = runner.invoke(cli.main, ['write_coverage', '/does/not/exist'])
+ assert result.output.splitlines()[-1].startswith(
+ 'Error: Invalid value for "profile_file": Could not open file:')
+ assert result.exit_code == 2
+
+
+@pytest.mark.parametrize('arg', ('-V', '--version'))
+def test_cli_version(arg, runner):
+ result = runner.invoke(cli.main, [arg])
+ assert result.output == 'covimerage, version %s\n' % __version__
+ assert result.exit_code == 0
+
+
+@pytest.mark.parametrize('arg', ('-h', '--help'))
+def test_cli_help(arg, runner):
+ result = runner.invoke(cli.main, [arg])
+ assert result.output.startswith('Usage:')
+ assert result.exit_code == 0
+
+ result = runner.invoke(cli.main, ['write_coverage', arg])
+ assert result.output.startswith('Usage:')
+ assert result.exit_code == 0
+
+
+def test_cli_run_with_args_fd(capfd):
+ ret = call(['covimerage', 'run', '--profile-file', '/doesnotexist',
+ 'echo', '--', '--no-profile', '%sMARKER'])
+ out, err = capfd.readouterr()
+ lines = err.splitlines()
+ assert lines == [
+ "Running cmd: echo -- --no-profile %sMARKER --cmd 'profile start /doesnotexist' --cmd 'profile! file ./*' (in {})".format(os.getcwd()), # noqa: E501
+ 'Error: The profile file (/doesnotexist) has not been created.']
+ assert ret == 1
+
+
+def test_cli_run_subprocess_exception(runner, mocker):
+ result = runner.invoke(cli.run, [os.devnull])
+ out = result.output.splitlines()
+ assert out[-1].startswith("Error: Failed to run ['/dev/null', '--cmd',")
+ assert out[-1].endswith("']: [Errno 13] Permission denied")
+ assert result.exit_code == 1
+
+
+def test_cli_run_args(runner, mocker, devnull, tmpdir):
+ m = mocker.patch('subprocess.call')
+ m.return_value = 1
+ result = runner.invoke(
+ cli.run, ['--no-wrap-profile', 'printf', '--headless'])
+ assert m.call_args[0] == (['printf', '--headless'],)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf --headless (in %s)' % os.getcwd(),
+ 'Error: Command exited non-zero: 1.']
+ assert result.exit_code == 1
+
+ m.return_value = 2
+ result = runner.invoke(
+ cli.run, ['--no-wrap-profile', '--', 'printf', '--headless'])
+ assert m.call_args[0] == (['printf', '--headless'],)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf --headless (in %s)' % os.getcwd(),
+ 'Error: Command exited non-zero: 2.']
+ assert result.exit_code == 2
+
+ m.return_value = 3
+ result = runner.invoke(
+ cli.run, ['--no-wrap-profile', 'printf', '--', '--headless'])
+ assert m.call_args[0] == (['printf', '--', '--headless'],)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % os.getcwd(),
+ 'Error: Command exited non-zero: 3.']
+ assert result.exit_code == 3
+
+ result = runner.invoke(cli.run, [
+ '--no-wrap-profile', '--no-report', '--profile-file', devnull.name,
+ '--no-write-data', 'printf', '--', '--headless'])
+ assert m.call_args[0] == (['printf', '--', '--headless'],)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % os.getcwd(),
+ 'Error: Command exited non-zero: 3.']
+
+ result = runner.invoke(cli.run, [
+ '--no-wrap-profile', '--no-report', '--profile-file', devnull.name,
+ '--source', devnull.name,
+ '--write-data', 'printf', '--', '--headless'])
+ assert m.call_args[0] == (['printf', '--', '--headless'],)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % os.getcwd(),
+ 'Parsing profile file /dev/null.',
+ 'Not writing coverage file: no data to report!',
+ 'Error: Command exited non-zero: 3.']
+
+ # Write data with non-sources only.
+ f = StringIO()
+ m.return_value = 0
+ with tmpdir.as_cwd() as old_dir:
+ profile_file = str(old_dir.join(
+ 'tests/fixtures/conditional_function.profile'))
+ result = runner.invoke(cli.run, [
+ '--no-wrap-profile', '--no-report',
+ '--profile-file', profile_file,
+ '--write-data', '--data-file', f,
+ 'printf', '--', '--headless'])
+ assert m.call_args[0] == (['printf', '--', '--headless'],)
+ out = result.output.splitlines()
+ assert out == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Ignoring non-source: %s' % str(tmpdir.join(
+ 'tests/test_plugin/conditional_function.vim')),
+ 'Not writing coverage file: no data to report!']
+ f.seek(0)
+ assert f.read() == ''
+
+ profiled_file = 'tests/test_plugin/conditional_function.vim'
+ profiled_file_content = open(profiled_file, 'r').read()
+ with tmpdir.as_cwd() as old_dir:
+ profile_file = str(old_dir.join(
+ 'tests/fixtures/conditional_function.profile'))
+ tmpdir.join(profiled_file).write(profiled_file_content, ensure=True)
+ tmpdir.join('not-profiled.vim').write('')
+ tmpdir.join('not-a-vim-file').write('')
+ result = runner.invoke(cli.run, [
+ '--no-wrap-profile', '--no-report',
+ '--profile-file', profile_file,
+ '--write-data', '--data-file', f,
+ 'printf', '--', '--headless'])
+ assert m.call_args[0] == (['printf', '--', '--headless'],)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Writing coverage file %r.' % f]
+ f.seek(0)
+
+ m.exit_code == 0
+ from covimerage.coveragepy import CoverageWrapper
+ cov = CoverageWrapper(data_file=f)
+ expected = {
+ str(tmpdir.join('not-profiled.vim')): [],
+ str(tmpdir.join('tests/test_plugin/conditional_function.vim')): [
+ 3, 8, 9, 11, 13, 14, 15, 17, 23]}
+ assert cov.lines == expected
+
+
+@pytest.mark.parametrize('with_append', (True, False))
+def test_cli_run_can_skip_writing_data(with_append, runner, tmpdir):
+ profiled_file = 'tests/test_plugin/conditional_function.vim'
+ profiled_file_content = open(profiled_file, 'r').read()
+ with tmpdir.as_cwd() as old_dir:
+ profile_file = str(old_dir.join(
+ 'tests/fixtures/conditional_function.profile'))
+ tmpdir.join(profiled_file).write(profiled_file_content, ensure=True)
+ args = ['--no-wrap-profile', '--profile-file', profile_file,
+ '--no-write-data', 'printf', '--', '--headless']
+ if with_append:
+ args.insert(0, '--append')
+ result = runner.invoke(cli.run, args)
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Name Stmts Miss Cover',
+ '----------------------------------------------------------------',
+ 'tests/test_plugin/conditional_function.vim 13 5 62%']
+ assert not tmpdir.join(DEFAULT_COVERAGE_DATA_FILE).exists()
+
+
+def test_cli_run_report_fd(capfd, tmpdir):
+ profile_fname = 'tests/fixtures/conditional_function.profile'
+ with open(profile_fname, 'r') as f:
+ profile_lines = f.readlines()
+ profile_lines[0] = 'SCRIPT tests/test_plugin/conditional_function.vim\n'
+
+ tmp_profile_fname = str(tmpdir.join('tmp.profile'))
+ with open(tmp_profile_fname, 'w') as f:
+ f.writelines(profile_lines)
+ data_file = str(tmpdir.join('datafile'))
+ args = ['--no-wrap-profile', '--profile-file', tmp_profile_fname,
+ '--source', 'tests/test_plugin/conditional_function.vim',
+ '--data-file', data_file, 'true']
+ exit_code = call(['covimerage', 'run'] + args)
+ out, err = capfd.readouterr()
+ assert exit_code == 0, err
+ assert out.splitlines() == [
+ 'Name Stmts Miss Cover',
+ '----------------------------------------------------------------',
+ 'tests/test_plugin/conditional_function.vim 13 5 62%']
+ assert err.splitlines() == [
+ 'Running cmd: true (in %s)' % str(os.getcwd()),
+ 'Parsing profile file %s.' % tmp_profile_fname,
+ 'Writing coverage file %s.' % data_file]
+
+ # Same, but to some file.
+ ofname = str(tmpdir.join('ofname'))
+ exit_code = call(['covimerage', 'run', '--report-file', ofname] + args)
+ out, err = capfd.readouterr()
+ assert exit_code == 0, err
+ assert out == ''
+ assert err.splitlines() == [
+ 'Running cmd: true (in %s)' % str(os.getcwd()),
+ 'Parsing profile file %s.' % tmp_profile_fname,
+ 'Writing coverage file %s.' % data_file]
+ assert open(ofname).read().splitlines() == [
+ 'Name Stmts Miss Cover',
+ '----------------------------------------------------------------',
+ 'tests/test_plugin/conditional_function.vim 13 5 62%']
+
+
+def test_cli_call(capfd):
+ assert call(['covimerage', '--version']) == 0
+ out, err = capfd.readouterr()
+ assert out == 'covimerage, version %s\n' % __version__
+
+ assert call(['covimerage', '--help']) == 0
+ out, err = capfd.readouterr()
+ assert out.startswith('Usage:')
+
+ assert call(['covimerage', 'file not found']) == 2
+ out, err = capfd.readouterr()
+ err_lines = err.splitlines()
+ assert err_lines[0] == 'Usage: covimerage [OPTIONS] COMMAND [ARGS]...'
+ # click after 6.7 (9cfea14) includes: 'Try "covimerage --help" for help.'
+ assert err_lines[-2:] == [
+ '',
+ 'Error: No such command "file not found".']
+ assert out == ''
+
+ assert call(['covimerage', 'write_coverage', 'file not found']) == 2
+ out, err = capfd.readouterr()
+ err_lines = err.splitlines()
+ assert err_lines[-1] == (
+ 'Error: Invalid value for "profile_file": Could not open file: file not found: No such file or directory') # noqa: E501
+ assert out == ''
+
+
+def test_cli_call_verbosity_fd(capfd):
+ assert call(['covimerage', 'write_coverage', os.devnull]) == 1
+ out, err = capfd.readouterr()
+ assert out == ''
+ assert err.splitlines() == [
+ 'Not writing coverage file: no data to report!',
+ 'Error: No data to report.']
+
+ assert call(['covimerage', '-v', 'write_coverage', os.devnull]) == 1
+ out, err = capfd.readouterr()
+ assert out == ''
+ assert err.splitlines() == [
+ 'Parsing file: /dev/null',
+ 'source_files: []',
+ 'Not writing coverage file: no data to report!',
+ 'Error: No data to report.']
+
+ assert call(['covimerage', '-vvvv', 'write_coverage', os.devnull]) == 1
+ out, err = capfd.readouterr()
+ assert out == ''
+ assert err.splitlines() == [
+ 'Parsing file: /dev/null',
+ 'source_files: []',
+ 'Not writing coverage file: no data to report!',
+ 'Error: No data to report.']
+
+ assert call(['covimerage', '-vq', 'write_coverage', os.devnull]) == 1
+ out, err = capfd.readouterr()
+ assert out == ''
+ assert err.splitlines() == [
+ 'Not writing coverage file: no data to report!',
+ 'Error: No data to report.']
+
+ assert call(['covimerage', '-qq', 'write_coverage', os.devnull]) == 1
+ out, err = capfd.readouterr()
+ assert out == ''
+ assert err == 'Error: No data to report.\n'
+
+
+def test_cli_writecoverage_without_data(runner):
+ result = runner.invoke(cli.main, ['write_coverage', os.devnull])
+ assert result.output == '\n'.join([
+ 'Not writing coverage file: no data to report!',
+ 'Error: No data to report.',
+ ''])
+ assert result.exit_code == 1
+
+
+def test_cli_writecoverage_datafile(runner):
+ from covimerage.coveragepy import CoverageWrapper
+
+ f = StringIO()
+ result = runner.invoke(cli.main, ['write_coverage', '--data-file', f,
+ 'tests/fixtures/conditional_function.profile'])
+ assert result.output == '\n'.join([
+ 'Writing coverage file %s.' % f,
+ ''])
+ assert result.exit_code == 0
+
+ f.seek(0)
+ cov = CoverageWrapper(data_file=f)
+ assert cov.lines == {
+ os.path.abspath('tests/test_plugin/conditional_function.vim'): [
+ 3, 8, 9, 11, 13, 14, 15, 17, 23]}
+
+
+def test_cli_writecoverage_source(runner):
+ from covimerage.coveragepy import CoverageWrapper
+
+ f = StringIO()
+ result = runner.invoke(cli.main, [
+ 'write_coverage', '--data-file', f, '--source', '.',
+ 'tests/fixtures/conditional_function.profile'])
+ assert result.output == '\n'.join([
+ 'Writing coverage file %s.' % f,
+ ''])
+ assert result.exit_code == 0
+
+ f.seek(0)
+ cov = CoverageWrapper(data_file=f)
+ assert cov.lines[
+ os.path.abspath('tests/test_plugin/conditional_function.vim')] == [
+ 3, 8, 9, 11, 13, 14, 15, 17, 23]
+ assert cov.lines[
+ os.path.abspath('tests/test_plugin/autoload/test_plugin.vim')] == []
+
+
+def test_coverage_plugin_for_annotate_merged_conditionals(runner, capfd,
+ tmpdir):
+ tmpfile = str(tmpdir.join('.coverage'))
+ result = runner.invoke(cli.main, [
+ 'write_coverage', '--data-file', tmpfile,
+ 'tests/fixtures/merged_conditionals-0.profile',
+ 'tests/fixtures/merged_conditionals-1.profile',
+ 'tests/fixtures/merged_conditionals-2.profile'])
+ assert result.output == '\n'.join([
+ 'Writing coverage file %s.' % tmpfile,
+ ''])
+ assert result.exit_code == 0
+
+ coveragerc = str(tmpdir.join('.coveragerc'))
+ with open(coveragerc, 'w') as f:
+ f.write('[run]\nplugins = covimerage')
+
+ exit_code = call(['env', 'COVERAGE_FILE=%s' % tmpfile,
+ 'coverage', 'annotate', '--rcfile', coveragerc,
+ '--directory', str(tmpdir)])
+ out, err = capfd.readouterr()
+ assert exit_code == 0, (err, out)
+ ann_fname = 'tests_test_plugin_merged_conditionals_vim,cover'
+ annotated_lines = tmpdir.join(ann_fname).read().splitlines()
+ assert annotated_lines == [
+ '> " Generate profile output for merged profiles.',
+ "> let cond = get(g:, 'test_conditional', 0)",
+ ' ',
+ '> if cond == 1',
+ '> let foo = 1',
+ '> elseif cond == 2',
+ '> let foo = 2',
+ '> elseif cond == 3',
+ '! let foo = 3',
+ '! else',
+ "> let foo = 'else'",
+ '> endif',
+ ' ',
+ '> function F(...)',
+ '> if a:1 == 1',
+ '> let foo = 1',
+ '> elseif a:1 == 2',
+ '> let foo = 2',
+ '> elseif a:1 == 3',
+ '! let foo = 3',
+ '! else',
+ "> let foo = 'else'",
+ '> endif',
+ '> endfunction',
+ ' ',
+ '> call F(cond)',
+ ]
+
+
+def test_report_profile_or_data_file(runner, tmpdir):
+ from covimerage.cli import DEFAULT_COVERAGE_DATA_FILE
+
+ result = runner.invoke(cli.main, [
+ 'report', '--data-file', '/does/not/exist'])
+ assert result.output.splitlines()[-1] == \
+ 'Error: Invalid value for "--data-file": Could not open file: /does/not/exist: No such file or directory' # noqa: E501
+ assert result.exit_code == 2
+
+ result = runner.invoke(cli.main, [
+ 'report', '--data-file', os.devnull])
+ cov_exc = 'CoverageException("Doesn\'t seem to be a coverage.py data file",)' # noqa: E501
+ assert result.output.splitlines()[-1] == \
+ 'Error: Coverage could not read data_file: /dev/null (%s)' % cov_exc
+ assert result.exit_code == 1
+
+ with tmpdir.as_cwd():
+ result = runner.invoke(cli.main, ['report'])
+ assert result.output.splitlines()[-1] == \
+ 'Error: Invalid value for "--data-file": Could not open file: %s: No such file or directory' % DEFAULT_COVERAGE_DATA_FILE # noqa: E501
+ assert result.exit_code == 2
+
+ result = runner.invoke(cli.main, ['report', '/does/not/exist'])
+ assert result.output.splitlines()[-1] == \
+ 'Error: Invalid value for "profile_file": Could not open file: /does/not/exist: No such file or directory' # noqa: E501
+ assert result.exit_code == 2
+
+ result = runner.invoke(cli.main, [
+ 'report', 'tests/fixtures/merged_conditionals-0.profile'])
+ assert result.output.splitlines() == [
+ 'Name Stmts Miss Cover',
+ '---------------------------------------------------------------',
+ 'tests/test_plugin/merged_conditionals.vim 19 12 37%']
+ assert result.exit_code == 0
+
+
+def test_report_source(runner, tmpdir, devnull):
+ result = runner.invoke(cli.main, [
+ 'report', '--source', '.', '/does/not/exist'])
+ assert result.output.splitlines()[-1] == \
+ 'Error: Invalid value for "profile_file": Could not open file: /does/not/exist: No such file or directory' # noqa: E501
+ assert result.exit_code == 2
+
+ result = runner.invoke(cli.main, [
+ 'report', '--source', '.', devnull.name])
+ out = result.output.splitlines()
+ assert any(l.startswith('tests/test_plugin/vimrc') # pragma: no branch
+ for l in out)
+ testplugin_fname = 'tests/test_plugin/autoload/test_plugin.vim'
+ assert any(testplugin_fname in l for l in out) # pragma: no branch
+ assert out[-1].startswith('TOTAL')
+ assert out[-1].endswith(' 0%')
+ assert result.exit_code == 0
+
+ result = runner.invoke(cli.main, [
+ 'report', devnull.name, '--source', '.'])
+ out = result.output.splitlines()
+ assert any(testplugin_fname in l for l in out) # pragma: no branch
+ assert out[-1].startswith('TOTAL')
+ assert out[-1].endswith(' 0%')
+ assert result.exit_code == 0
+
+ result = runner.invoke(cli.main, [
+ 'report', '--source', '.'])
+ out = result.output.splitlines()
+ assert out[-1] == 'Error: --source can only be used with PROFILE_FILE.'
+ assert result.exit_code == 2
+
+ result = runner.invoke(cli.main, [
+ 'report', '--source', 'tests/test_plugin/merged_conditionals.vim',
+ 'tests/fixtures/merged_conditionals-0.profile'])
+ assert result.output.splitlines() == [
+ 'Name Stmts Miss Cover',
+ '---------------------------------------------------------------',
+ 'tests/test_plugin/merged_conditionals.vim 19 12 37%']
+ assert result.exit_code == 0
+
+
+def test_cli_xml(runner, tmpdir):
+ """Smoke test for the xml command."""
+ result = runner.invoke(cli.main, [
+ 'write_coverage', '--data-file', str(tmpdir.join('.coverage')),
+ 'tests/fixtures/merged_conditionals-0.profile'])
+ with tmpdir.as_cwd():
+ result = runner.invoke(cli.main, ['xml', '--data-file', '.coverage'])
+ assert result.exit_code == 0
+
+ with open('coverage.xml') as f:
+ xml = f.read()
+ assert 'filename="%s/tests/test_plugin/merged_conditionals.vim' % (
+ os.getcwd()) in xml
+
+
+def test_run_handles_exit_code_from_python_fd(capfd):
+ ret = call(['covimerage', 'run',
+ 'python', '-c', 'print("output"); import sys; sys.exit(42)'])
+ out, err = capfd.readouterr()
+ assert 'Error: Command exited non-zero: 42.' in err.splitlines()
+ assert out == 'output\n'
+ assert ret == 42
+
+
+def test_run_handles_exit_code_from_python_pty_fd(capfd):
+ ret = call(['covimerage', 'run', '--profile-file', '/not/used',
+ 'python', '-c',
+ "import pty; pty.spawn(['/bin/sh', '-c', "
+ "'printf output; exit 42'])"])
+ out, err = capfd.readouterr()
+ assert ('Error: The profile file (/not/used) has not been created.' in
+ err.splitlines())
+ assert out == 'output'
+ assert ret == 1
+
+
+def test_run_append_with_empty_data(runner, tmpdir):
+ with tmpdir.as_cwd() as old_dir:
+ profile_file = str(old_dir.join(
+ 'tests/fixtures/conditional_function.profile'))
+ data_file = StringIO()
+ result = runner.invoke(cli.run, [
+ '--append', '--no-wrap-profile', '--profile-file', profile_file,
+ '--data-file', data_file, 'printf', '--', '--headless'])
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Error: Coverage could not read data_file: %r (CoverageException("Doesn\'t seem to be a coverage.py data file",))' % data_file, # noqa: E501
+ ]
+ assert result.exit_code == 1
+
+
+def test_run_append_with_data(runner, tmpdir, covdata_empty):
+ profiled_file = 'tests/test_plugin/conditional_function.vim'
+ profiled_file_content = open(profiled_file, 'r').read()
+ tmpdir.join(profiled_file).write(profiled_file_content, ensure=True)
+ with tmpdir.as_cwd() as old_dir:
+ profile_file = str(old_dir.join(
+ 'tests/fixtures/conditional_function.profile'))
+ result = runner.invoke(cli.run, [
+ '--append', '--no-wrap-profile', '--profile-file', profile_file,
+ 'printf', '--', '--headless'])
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Writing coverage file .coverage.covimerage.',
+ 'Name Stmts Miss Cover',
+ '----------------------------------------------------------------',
+ 'tests/test_plugin/conditional_function.vim 13 5 62%']
+ assert result.exit_code == 0
+
+ # The same again.
+ result = runner.invoke(cli.run, [
+ '--append', '--no-wrap-profile', '--profile-file', profile_file,
+ 'printf', '--', '--headless'])
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Writing coverage file .coverage.covimerage.',
+ 'Name Stmts Miss Cover',
+ '----------------------------------------------------------------',
+ 'tests/test_plugin/conditional_function.vim 13 5 62%']
+ assert result.exit_code == 0
+
+ # Append another profile.
+ another_profiled_file = 'tests/test_plugin/merged_conditionals.vim'
+ tmpdir.join(another_profiled_file).write(
+ old_dir.join(another_profiled_file).read(), ensure=True)
+ tmpdir.join(profiled_file).write(profiled_file_content, ensure=True)
+ profile_file = str(old_dir.join(
+ 'tests/fixtures/merged_conditionals-0.profile'))
+ tmpdir.join(profiled_file).write(profiled_file_content, ensure=True)
+ result = runner.invoke(cli.run, [
+ '--append', '--no-wrap-profile', '--profile-file', profile_file,
+ 'printf', '--', '--headless'])
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % profile_file,
+ 'Writing coverage file .coverage.covimerage.',
+ 'Name Stmts Miss Cover',
+ '----------------------------------------------------------------',
+ 'tests/test_plugin/conditional_function.vim 13 5 62%',
+ 'tests/test_plugin/merged_conditionals.vim 19 12 37%',
+ '----------------------------------------------------------------',
+ 'TOTAL 32 17 47%']
+ assert result.exit_code == 0
+
+
+def test_run_report_without_data(tmpdir, runner, devnull):
+ with tmpdir.as_cwd():
+ result = runner.invoke(cli.run, [
+ '--no-write-data', '--no-wrap-profile',
+ '--profile-file', devnull.name,
+ 'printf', '--', '--headless'])
+ assert result.output.splitlines() == [
+ 'Running cmd: printf -- --headless (in %s)' % str(tmpdir),
+ 'Parsing profile file %s.' % devnull.name,
+ 'Error: No data to report.']
+ assert result.exit_code == 1
diff --git a/tests/test_coveragepy.py b/tests/test_coveragepy.py
new file mode 100644
index 00000000..71d05b8b
--- /dev/null
+++ b/tests/test_coveragepy.py
@@ -0,0 +1,181 @@
+try:
+ from StringIO import StringIO
+except ImportError:
+ from io import StringIO
+import sys
+
+import attr
+import coverage
+import pytest
+
+
+def test_filereporter():
+ from covimerage.coveragepy import FileReporter
+
+ f = FileReporter('/doesnotexist')
+ assert repr(f) == ""
+
+ with pytest.raises(coverage.misc.NoSource) as excinfo:
+ f.lines()
+ assert excinfo.value.args == (
+ "[Errno 2] No such file or directory: '/doesnotexist'",)
+
+
+def test_filereporter_source_handles_latin1(tmpdir):
+ from covimerage.coveragepy import FileReporter
+
+ with tmpdir.as_cwd():
+ with open('iso.txt', 'wb') as f:
+ f.write(b'Hellstr\xf6m')
+ with open('utf8.txt', 'wb') as f:
+ f.write(b'Hellstr\xc3\xb6m')
+
+ assert FileReporter('iso.txt').source().encode(
+ 'utf-8') == b'Hellstr\xc3\xb6m'
+ assert FileReporter('iso.txt').source().encode(
+ 'utf-8') == b'Hellstr\xc3\xb6m'
+
+
+@pytest.mark.skipif(sys.version_info[0] == 3 and sys.version_info[1] < 5,
+ reason='Failed to patch open with py33/py34.')
+def test_filereporter_source_exception(mocker, devnull):
+ from covimerage.coveragepy import CoverageWrapperException, FileReporter
+
+ class CustomException(Exception):
+ pass
+
+ m = mocker.mock_open()
+ m.return_value.read.side_effect = CustomException
+ mocker.patch('covimerage.coveragepy.open', m)
+
+ f = FileReporter(devnull.name)
+ with pytest.raises(CoverageWrapperException) as excinfo:
+ f.source()
+
+ assert isinstance(excinfo.value.orig_exc, CustomException)
+
+
+@pytest.fixture
+def coverage_fileobj():
+ return StringIO('\n'.join(['!coverage.py: This is a private format, don\'t read it directly!{"lines":{"/test_plugin/conditional_function.vim":[17,3,23,8,9,11,13,14,15]},"file_tracers":{"/test_plugin/conditional_function.vim":"covimerage.CoveragePlugin"}}'])) # noqa: E501
+
+
+def test_coveragedata(coverage_fileobj):
+ import coverage
+ from covimerage.coveragepy import CoverageData, CoverageWrapperException
+
+ with pytest.raises(TypeError) as excinfo:
+ CoverageData(data_file='foo', cov_data='bar')
+ assert excinfo.value.args == (
+ 'data and data_file are mutually exclusive.',)
+
+ data = CoverageData()
+ assert isinstance(data.cov_data, coverage.data.CoverageData)
+
+ with pytest.raises(TypeError) as excinfo:
+ CoverageData(cov_data='foo')
+ assert excinfo.value.args == (
+ 'data needs to be of type coverage.data.CoverageData',)
+
+ with pytest.raises(CoverageWrapperException) as excinfo:
+ CoverageData(data_file='/does/not/exist')
+ assert excinfo.value.args == (
+ 'Coverage could not read data_file: /does/not/exist',)
+ assert isinstance(excinfo.value.orig_exc, coverage.misc.CoverageException)
+
+ f = StringIO()
+ with pytest.raises(CoverageWrapperException) as excinfo:
+ CoverageData(data_file=f)
+ e = excinfo.value
+ assert isinstance(e.orig_exc, coverage.misc.CoverageException)
+ assert e.message == 'Coverage could not read data_file: %s' % f
+ assert e.format_message() == '%s (%r)' % (e.message, e.orig_exc)
+ assert str(e) == e.format_message()
+ assert repr(e) == 'CoverageWrapperException(message=%r, orig_exc=%r)' % (
+ e.message, e.orig_exc)
+
+ cov_data = CoverageData(data_file=coverage_fileobj)
+ with pytest.raises(attr.exceptions.FrozenInstanceError):
+ cov_data.data = 'foo'
+
+ assert cov_data.lines == {
+ '/test_plugin/conditional_function.vim': [
+ 3, 8, 9, 11, 13, 14, 15, 17, 23]}
+
+
+def test_coveragedata_empty(covdata_empty):
+ from covimerage.coveragepy import CoverageData
+
+ f = StringIO()
+ data = CoverageData()
+ data.cov_data.write_fileobj(f)
+ f.seek(0)
+ assert f.read() == covdata_empty
+
+
+def test_coveragewrapper(coverage_fileobj, devnull):
+ import coverage
+ from covimerage.coveragepy import (
+ CoverageData, CoverageWrapper, CoverageWrapperException)
+
+ cov_data = CoverageWrapper()
+ assert cov_data.lines == {}
+ assert isinstance(cov_data.data, CoverageData)
+
+ cov_data = CoverageWrapper(data=coverage.data.CoverageData())
+ assert cov_data.lines == {}
+ assert isinstance(cov_data.data, CoverageData)
+
+ with pytest.raises(TypeError):
+ CoverageWrapper(data_file='foo', data='bar')
+
+ with pytest.raises(TypeError):
+ CoverageWrapper(data_file='foo', data=CoverageData())
+
+ cov = CoverageWrapper(data_file=coverage_fileobj)
+ with pytest.raises(attr.exceptions.FrozenInstanceError):
+ cov.data = 'foo'
+
+ assert cov.lines == {
+ '/test_plugin/conditional_function.vim': [
+ 3, 8, 9, 11, 13, 14, 15, 17, 23]}
+
+ assert isinstance(cov._cov_obj, coverage.control.Coverage)
+ assert cov._cov_obj.data is cov.data.cov_data
+
+ with pytest.raises(CoverageWrapperException) as excinfo:
+ CoverageWrapper(data_file=devnull.name)
+ assert excinfo.value.args == (
+ 'Coverage could not read data_file: /dev/null',)
+
+ f = StringIO()
+ with pytest.raises(CoverageWrapperException) as excinfo:
+ CoverageWrapper(data_file=f)
+ e = excinfo.value
+ assert isinstance(e.orig_exc, coverage.misc.CoverageException)
+ assert e.message == 'Coverage could not read data_file: %s' % f
+ assert e.format_message() == '%s (%r)' % (e.message, e.orig_exc)
+ assert str(e) == e.format_message()
+ assert repr(e) == 'CoverageWrapperException(message=%r, orig_exc=%r)' % (
+ e.message, e.orig_exc)
+
+
+def test_coveragewrapper_accepts_data():
+ from covimerage.coveragepy import CoverageData, CoverageWrapper
+
+ data = CoverageData()
+ cov = CoverageWrapper(data=data)
+ assert cov.data is data
+
+
+def test_coveragewrapperexception():
+ from covimerage.coveragepy import CoverageWrapperException
+
+ assert CoverageWrapperException('foo').format_message() == 'foo'
+
+ with pytest.raises(CoverageWrapperException) as excinfo:
+ try:
+ raise Exception('orig')
+ except Exception as orig_exc:
+ raise CoverageWrapperException('bar', orig_exc=orig_exc)
+ assert excinfo.value.format_message() == "bar (Exception('orig',))"
diff --git a/tests/test_logging.py b/tests/test_logging.py
new file mode 100644
index 00000000..06535638
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,19 @@
+import pytest
+
+
+def test_logging_error_causes_exception(capfd):
+ from covimerage import LOGGER
+
+ with pytest.raises(Exception) as excinfo:
+ LOGGER.info('Wrong:', 'no %s')
+ assert excinfo.value.args[0] == 'Internal logging error'
+ out, err = capfd.readouterr()
+
+ lines = err.splitlines()
+ assert any((l.startswith('Traceback') for l in lines)) # pragma: no branch
+
+ if not lines[-1].startswith('Logged from file test_logging.py, line '):
+ assert lines[-2:] == [
+ "Message: 'Wrong:'",
+ "Arguments: ('no %s',)"]
+ assert 'TypeError: not all arguments converted during string formatting' in lines # noqa: E501
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 00000000..9bb92dd2
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,424 @@
+import logging
+
+import coverage
+import pytest
+
+from covimerage._compat import FileNotFoundError, StringIO
+
+
+def test_main_import():
+ from covimerage import __main__ # noqa: F401
+
+
+def test_profile_repr_lines():
+ from covimerage import Line, Profile, Script
+
+ with pytest.raises(TypeError):
+ Profile()
+
+ p = Profile('profile-path')
+ s = Script('script-path')
+ p.scripts.append(s)
+ assert repr(p.lines) == '{%r: {}}' % s
+ assert repr(s) == "Script(path='script-path', sourced_count=None)"
+
+ l = Line('line1')
+ s.lines[1] = l
+ assert repr(p.lines) == ('{%r: {1: %r}}' % (s, l))
+
+
+def test_profile_fname_or_fobj(caplog, devnull):
+ from covimerage import Profile
+
+ with pytest.raises(FileNotFoundError) as excinfo:
+ Profile('/does/not/exist').parse()
+ assert str(excinfo.value) == \
+ "[Errno 2] No such file or directory: '/does/not/exist'"
+
+ with caplog.at_level(logging.NOTSET, logger='covimerage'):
+ Profile(devnull).parse()
+ msgs = [(r.levelname, r.message) for r in caplog.records]
+ assert msgs == [('DEBUG', 'Parsing file: /dev/null')]
+
+ fileobj = StringIO('')
+ with caplog.at_level(logging.NOTSET, logger='covimerage'):
+ Profile(fileobj).parse()
+ msgs = [(r.levelname, r.message) for r in caplog.records]
+ assert msgs[-1] == ('DEBUG', 'Parsing file: %s' % fileobj)
+ assert len(msgs) == 2
+
+
+def test_parse_count_and_times():
+ from covimerage import parse_count_and_times
+
+ f = parse_count_and_times
+ assert f(' 1 0.000035 Foo') == (1, None, 0.000035)
+ assert f(' 1 0.000037 Foo') == (1, 0.000037, None)
+ assert f('') == (0, None, None)
+
+
+def test_line():
+ from covimerage import Line
+
+ l = Line(' 1 0.000005 Foo')
+ assert repr(l) == "Line(line=' 1 0.000005 Foo', count=None, total_time=None, self_time=None)" # noqa
+
+
+def test_profile_parse():
+ from covimerage import Line, Profile
+
+ fname = 'tests/fixtures/test_plugin.profile'
+ p = Profile(fname)
+ p.parse()
+
+ assert len(p.scripts) == 1
+
+ script_fname = '/test_plugin/autoload/test_plugin.vim'
+ # assert script_fname in p.scripts
+ s = p.scripts[0]
+ assert s.path == script_fname
+
+ assert len(s.lines) == 8
+ assert s.lines == {
+ 1: Line(line='function! test_plugin#func1(a) abort', count=None,
+ total_time=None, self_time=None),
+ 2: Line(line=" echom 'func1'", count=1, total_time=None,
+ self_time=3.5e-05),
+ 3: Line(line='endfunction', count=None, total_time=None,
+ self_time=None),
+ 4: Line(line='', count=None, total_time=None, self_time=None),
+ 5: Line(line='function! test_plugin#func2(a) abort', count=1,
+ total_time=None, self_time=5e-06),
+ 6: Line(line=" echom 'func2'", count=0, total_time=None,
+ self_time=None),
+ 7: Line(line='endfunction', count=None, total_time=None,
+ self_time=None),
+ 8: Line(line='', count=None, total_time=None, self_time=None)}
+
+
+def test_profile_parse_handles_cannot_open_file(caplog):
+ from covimerage import Profile
+
+ file_object = StringIO('\n'.join([
+ 'SCRIPT /tmp/nvimDG3tAV/801',
+ 'Sourced 1 time',
+ 'Total time: 0.046577',
+ ' Self time: 0.000136',
+ '',
+ 'count total (s) self (s)',
+ 'Cannot open file!',
+ ]))
+ p = Profile('fake')
+ p._parse(file_object)
+
+ assert len(p.scripts) == 1
+ s = p.scripts[0]
+ assert not s.lines
+ assert len(s.lines) == 0
+ assert s.sourced_count == 1
+ msgs = [r.message for r in caplog.records]
+ assert msgs == [
+ 'Could not parse count/times from line: Cannot open file! (fake:7).']
+
+
+def test_find_func_in_source():
+ from covimerage import Function, Profile, Script
+
+ s = Script('fake')
+ s.parse_script_line(1, 'fun! Python_jump(mode, motion, flags) range')
+ s.parse_script_line(2, 'fu g:Foo()')
+ s.parse_script_line(3, 'fun\tsome#autoload()')
+ p = Profile('fake', scripts=[s])
+
+ f = p.find_func_in_source
+
+ assert f(Function(name='247_Python_jump')) == (s, 1)
+ assert f(Function(name='Foo')) == (s, 2)
+ assert f(Function(name='some#autoload')) == (s, 3)
+
+
+def test_profile_parse_dict_function():
+ from covimerage import Profile
+
+ fname = 'tests/fixtures/dict_function.profile'
+ p = Profile(fname)
+ p.parse()
+
+ assert len(p.scripts) == 1
+
+ script_fname = '/test_plugin/dict_function.vim'
+ # assert script_fname in p.scripts
+ s = p.scripts[0]
+ assert s.path == script_fname
+
+ assert s.dict_functions == {3}
+ assert s.mapped_dict_functions == {3}
+
+ assert len(s.lines) == 13
+ assert [l.line for l in s.lines.values()] == [
+ '" Test parsing of dict function.',
+ 'let obj = {}',
+ 'function! obj.dict_function(arg) abort',
+ ' if a:arg',
+ ' echom a:arg',
+ ' else',
+ ' echom a:arg',
+ ' endif',
+ 'endfunction',
+ '',
+ 'call obj.dict_function(0)',
+ 'call obj.dict_function(1)',
+ 'call obj.dict_function(2)',
+ ]
+
+ assert s.lines[4].count == 3
+ assert s.lines[5].count == 2
+
+
+def test_profile_parse_dict_function_with_same_source(caplog):
+ from covimerage import Profile
+
+ fname = 'tests/fixtures/dict_function_with_same_source.profile'
+ with caplog.at_level(logging.NOTSET, logger='covimerage'):
+ p = Profile(fname)
+ p.parse()
+
+ assert len(p.scripts) == 1
+
+ script_fname = '/test_plugin/dict_function_with_same_source.vim'
+ s = p.scripts[0]
+ assert s.path == script_fname
+
+ assert s.dict_functions == {3, 12}
+ assert s.mapped_dict_functions == {3, 12}
+
+ N = None
+ assert [(l.count, l.line) for l in s.lines.values()] == [
+ (N, '" Test parsing of dict function (with same source).'),
+ (1, 'let obj1 = {}'),
+ (1, 'function! obj1.dict_function(arg) abort'),
+ (1, ' if a:arg'),
+ (1, ' echom a:arg'),
+ (1, ' else'),
+ (0, ' echom a:arg'),
+ (0, ' endif'),
+ (N, 'endfunction'),
+ (N, ''),
+ (1, 'let obj2 = {}'),
+ (1, 'function! obj2.dict_function(arg) abort'),
+ (3, ' if a:arg'),
+ (2, ' echom a:arg'),
+ (2, ' else'),
+ (1, ' echom a:arg'),
+ (1, ' endif'),
+ (N, 'endfunction'),
+ (N, ''),
+ (1, 'call obj2.dict_function(0)'),
+ (1, 'call obj2.dict_function(1)'),
+ (1, 'call obj2.dict_function(2)'),
+ (1, 'call obj1.dict_function(3)')]
+
+ msgs = [r.message for r in caplog.records]
+ assert "Found multiple sources for anonymous function 1 (/test_plugin/dict_function_with_same_source.vim:3, /test_plugin/dict_function_with_same_source.vim:12)." in msgs # noqa
+ assert "Found multiple sources for anonymous function 2 (/test_plugin/dict_function_with_same_source.vim:3, /test_plugin/dict_function_with_same_source.vim:12)." in msgs # noqa
+ assert "Found already mapped dict function again (/test_plugin/dict_function_with_same_source.vim:3)." in msgs # noqa
+
+
+def test_profile_parse_dict_function_with_continued_lines():
+ from covimerage import Profile
+
+ fname = 'tests/fixtures/dict_function_with_continued_lines.profile'
+ p = Profile(fname)
+ p.parse()
+
+ assert len(p.scripts) == 1
+ s = p.scripts[0]
+
+ assert s.dict_functions == {3}
+ assert s.mapped_dict_functions == {3}
+
+ N = None
+ assert [(l.count, l.line) for l in s.lines.values()] == [
+ (N, '" Test parsing of dict function with continued lines.'),
+ (1, 'let obj = {}'),
+ (1, 'function! obj.dict_function(arg) abort'),
+ (3, ' if a:arg'),
+ (2, ' echom'),
+ (N, ' \\ a:arg'),
+ (2, ' else'),
+ (1, ' echom a:arg'),
+ (1, ' endif'),
+ (N, 'endfunction'),
+ (N, ''),
+ (1, 'call obj.dict_function(0)'),
+ (1, 'call obj.dict_function(1)'),
+ (1, 'call obj.dict_function(1)')]
+
+
+def test_profile_continued_lines():
+ from covimerage import Profile
+
+ fname = 'tests/fixtures/continued_lines.profile'
+ p = Profile(fname)
+ p.parse()
+
+ assert len(p.scripts) == 1
+ s = p.scripts[0]
+
+ N = None
+ assert [(l.count, l.line) for l in s.lines.values()] == [
+ (N, 'echom 1'),
+ (1, 'echom 2'),
+ (N, ' \\ 3'),
+ (1, 'echom 4')]
+
+
+def test_conditional_functions():
+ from covimerage import Profile
+
+ fname = 'tests/fixtures/conditional_function.profile'
+ p = Profile(fname)
+ p.parse()
+
+ assert len(p.scripts) == 1
+ s = p.scripts[0]
+
+ N = None
+ assert [(l.count, l.line) for l in s.lines.values()] == [
+ (N, '" Test for detection of conditional functions.'),
+ (N, ''),
+ (1, 'if 0'),
+ (N, ' function Foo()'),
+ (N, ' return 1'),
+ (N, ' endfunction'),
+ (N, 'else'),
+ (1, ' function Foo()'),
+ (1, ' return 1'),
+ (N, ' endfunction'),
+ (1, 'endif'),
+ (N, ''),
+ (1, 'if Foo()'),
+ (1, ' function Bar()'),
+ (1, ' echom 1'),
+ (N, ' endfunction'),
+ (1, 'else'),
+ (N, ' function Bar()'),
+ (N, ' echom 1'),
+ (N, ' endfunction'),
+ (N, 'endif'),
+ (N, ''),
+ (1, 'call Bar()')]
+
+
+def test_merged_profiles():
+ from covimerage import MergedProfiles, Profile
+
+ p1 = Profile('tests/fixtures/merge-1.profile')
+ p1.parse()
+ p2 = Profile('tests/fixtures/merge-2.profile')
+ p2.parse()
+ assert p1.scriptfiles == p2.scriptfiles
+
+ m = MergedProfiles([p1, p2])
+
+ assert list(m.scripts) == p1.scripts + p2.scripts
+ assert m.scriptfiles == p1.scriptfiles
+
+ N = None
+ s_fname = '/test_plugin/merged_profiles.vim'
+ assert [(l.count, l.line) for lnum, l in m.lines[s_fname].items()] == [
+ (N, '" Generate profile output for merged profiles.'),
+ (N, '" Used merged_profiles-init.vim'),
+ (2, "if !exists('s:conditional')"),
+ (1, ' function! F()'),
+ (1, ' echom 1'),
+ (N, ' endfunction'),
+ (1, ' let s:conditional = 1'),
+ (1, ' echom 1'),
+ (1, ' call F()'),
+ (N, ' call NeomakeTestsProfileRestart()'),
+ (N, " exe 'source ' . expand('')"),
+ (1, 'else'),
+ (1, ' function! F()'),
+ (1, ' echom 2'),
+ (N, ' endfunction'),
+ (1, ' echom 2'),
+ (1, ' call F()'),
+ (1, 'endif'),
+ (N, '')]
+
+
+def test_mergedprofiles_fixes_line_count():
+ from covimerage import MergedProfiles, Profile
+
+ fname = 'tests/fixtures/continued_lines.profile'
+ p = Profile(fname)
+ p.parse()
+
+ script = p.scripts[0]
+
+ N = None
+ assert [(l.count, l.line) for l in script.lines.values()] == [
+ (N, 'echom 1'),
+ (1, 'echom 2'),
+ (N, ' \\ 3'),
+ (1, 'echom 4')]
+
+ m = MergedProfiles([p])
+ assert [(l.count, l.line) for l in m.lines[script.path].values()] == [
+ (1, 'echom 1'),
+ (1, 'echom 2'),
+ (N, ' \\ 3'),
+ (1, 'echom 4')]
+
+
+def test_merged_profiles_get_coveragepy_data():
+ from covimerage import MergedProfiles
+
+ m = MergedProfiles([])
+ cov_data = m.get_coveragepy_data()
+ assert isinstance(cov_data, coverage.CoverageData)
+ assert repr(cov_data) == '' # noqa: E501
+
+
+def test_merged_profiles_write_coveragepy_data_handles_fname_and_fobj(
+ mocker, caplog):
+ from covimerage import MergedProfiles
+
+ m = MergedProfiles([])
+ mocked_data = mocker.Mock()
+ mocker.patch.object(m, 'get_coveragepy_data', return_value=mocked_data)
+
+ m.write_coveragepy_data()
+ assert mocked_data.write_file.call_args_list == [mocker.call('.coverage')]
+ assert mocked_data.write_fileobj.call_args_list == []
+
+ mocked_data.reset_mock()
+ fileobj = StringIO()
+ m.write_coveragepy_data(data_file=fileobj)
+ assert mocked_data.write_file.call_args_list == []
+ assert mocked_data.write_fileobj.call_args_list == [mocker.call(fileobj)]
+ msgs = [r.message for r in caplog.records]
+ assert msgs == [
+ 'Writing coverage file .coverage.',
+ 'Writing coverage file %s.' % fileobj]
+
+
+def test_mergedprofiles_caches_coveragepy_data(mocker):
+ from covimerage import MergedProfiles, Profile
+
+ m = MergedProfiles([])
+ spy = mocker.spy(m, '_get_coveragepy_data')
+
+ m.get_coveragepy_data()
+ assert spy.call_count == 1
+ m.get_coveragepy_data()
+ assert spy.call_count == 1
+
+ m.profiles += [Profile('foo')]
+ m.get_coveragepy_data()
+ assert spy.call_count == 2
+
+ m.profiles = [Profile('bar')]
+ m.get_coveragepy_data()
+ assert spy.call_count == 3
diff --git a/tests/test_plugin/autoload/test_plugin.vim b/tests/test_plugin/autoload/test_plugin.vim
new file mode 100644
index 00000000..aabd1329
--- /dev/null
+++ b/tests/test_plugin/autoload/test_plugin.vim
@@ -0,0 +1,19 @@
+" Comment
+" Another comment
+let foo = 1
+let bar = foo
+
+function! test_plugin#func1(a) abort
+ echom 'func1'
+endfunction
+
+function! test_plugin#func2(a) abort
+ echom 'func2.1'
+
+ echom 'func2.2'
+endfunction
+
+if foo == 2
+ echom 'not covered'
+endif
+
diff --git a/tests/test_plugin/conditional_function.vim b/tests/test_plugin/conditional_function.vim
new file mode 100644
index 00000000..12c5ff9e
--- /dev/null
+++ b/tests/test_plugin/conditional_function.vim
@@ -0,0 +1,23 @@
+" Test for detection of conditional functions.
+
+if 0
+ function Foo()
+ return 1
+ endfunction
+else
+ function Foo()
+ return 1
+ endfunction
+endif
+
+if Foo()
+ function Bar()
+ echom 1
+ endfunction
+else
+ function Bar()
+ echom 1
+ endfunction
+endif
+
+call Bar()
diff --git a/tests/test_plugin/continued_lines.vim b/tests/test_plugin/continued_lines.vim
new file mode 100644
index 00000000..e4d50825
--- /dev/null
+++ b/tests/test_plugin/continued_lines.vim
@@ -0,0 +1,5 @@
+echom 1
+echom 2
+ \ 3
+echom 4
+ \ 5
diff --git a/tests/test_plugin/dict_function.vim b/tests/test_plugin/dict_function.vim
new file mode 100644
index 00000000..3ebb730e
--- /dev/null
+++ b/tests/test_plugin/dict_function.vim
@@ -0,0 +1,13 @@
+" Test parsing of dict function.
+let obj = {}
+function! obj.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+endfunction
+
+call obj.dict_function(0)
+call obj.dict_function(1)
+call obj.dict_function(2)
diff --git a/tests/test_plugin/dict_function_used_twice.vim b/tests/test_plugin/dict_function_used_twice.vim
new file mode 100644
index 00000000..d5529de2
--- /dev/null
+++ b/tests/test_plugin/dict_function_used_twice.vim
@@ -0,0 +1,24 @@
+" Test parsing of dict function that gets used twice.
+let base = {}
+function! base.base_function() abort dict
+ if 1
+ echom 1
+ else
+ echom 2
+ endif
+endfunction
+
+let obj1 = deepcopy(base)
+function! obj1.func() abort
+ call self.base_function()
+endfunction
+
+let obj2 = deepcopy(base)
+function! obj2.func() abort
+ call self.base_function()
+endfunction
+
+call obj1.func()
+call obj2.func()
+call obj1.base_function()
+call obj2.base_function()
diff --git a/tests/test_plugin/dict_function_with_continued_lines.vim b/tests/test_plugin/dict_function_with_continued_lines.vim
new file mode 100644
index 00000000..e2a5a082
--- /dev/null
+++ b/tests/test_plugin/dict_function_with_continued_lines.vim
@@ -0,0 +1,14 @@
+" Test parsing of dict function with continued lines.
+let obj = {}
+function! obj.dict_function(arg) abort
+ if a:arg
+ echom
+ \ a:arg
+ else
+ echom a:arg
+ endif
+endfunction
+
+call obj.dict_function(0)
+call obj.dict_function(1)
+call obj.dict_function(1)
diff --git a/tests/test_plugin/dict_function_with_same_source.vim b/tests/test_plugin/dict_function_with_same_source.vim
new file mode 100644
index 00000000..0721311f
--- /dev/null
+++ b/tests/test_plugin/dict_function_with_same_source.vim
@@ -0,0 +1,23 @@
+" Test parsing of dict function (with same source).
+let obj1 = {}
+function! obj1.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+endfunction
+
+let obj2 = {}
+function! obj2.dict_function(arg) abort
+ if a:arg
+ echom a:arg
+ else
+ echom a:arg
+ endif
+endfunction
+
+call obj2.dict_function(0)
+call obj2.dict_function(1)
+call obj2.dict_function(2)
+call obj1.dict_function(3)
diff --git a/tests/test_plugin/merged_conditionals.vim b/tests/test_plugin/merged_conditionals.vim
new file mode 100644
index 00000000..c276a678
--- /dev/null
+++ b/tests/test_plugin/merged_conditionals.vim
@@ -0,0 +1,26 @@
+" Generate profile output for merged profiles.
+let cond = get(g:, 'test_conditional', 0)
+
+if cond == 1
+ let foo = 1
+elseif cond == 2
+ let foo = 2
+elseif cond == 3
+ let foo = 3
+else
+ let foo = 'else'
+endif
+
+function F(...)
+ if a:1 == 1
+ let foo = 1
+ elseif a:1 == 2
+ let foo = 2
+ elseif a:1 == 3
+ let foo = 3
+ else
+ let foo = 'else'
+ endif
+endfunction
+
+call F(cond)
diff --git a/tests/test_plugin/merged_profiles-init.vim b/tests/test_plugin/merged_profiles-init.vim
new file mode 100644
index 00000000..0d668563
--- /dev/null
+++ b/tests/test_plugin/merged_profiles-init.vim
@@ -0,0 +1,15 @@
+let s:profile_count = 0
+function! NeomakeTestsProfileRestart()
+ let s:profile_count += 1
+ if s:profile_count > 1
+ echom 'stop'
+ profile stop
+ endif
+ let profile_file = printf('tests/fixtures/merge-%d.profile', s:profile_count)
+ exec 'profile start '.profile_file
+ exe 'profile! file ./*.vim'
+endfunction
+
+call NeomakeTestsProfileRestart()
+
+source test_plugin/merged_profiles.vim
diff --git a/tests/test_plugin/merged_profiles.vim b/tests/test_plugin/merged_profiles.vim
new file mode 100644
index 00000000..f9882bf6
--- /dev/null
+++ b/tests/test_plugin/merged_profiles.vim
@@ -0,0 +1,19 @@
+" Generate profile output for merged profiles.
+" Used merged_profiles-init.vim
+if !exists('s:conditional')
+ function! F()
+ echom 1
+ endfunction
+ let s:conditional = 1
+ echom 1
+ call F()
+ call NeomakeTestsProfileRestart()
+ exe 'source ' . expand('')
+else
+ function! F()
+ echom 2
+ endfunction
+ echom 2
+ call F()
+endif
+
diff --git a/tests/test_plugin/vimrc b/tests/test_plugin/vimrc
new file mode 100644
index 00000000..4f0adefd
--- /dev/null
+++ b/tests/test_plugin/vimrc
@@ -0,0 +1,12 @@
+" vimrc to run covimerage against the test plugin.
+
+let prof_fname = get(g:, 'prof_fname')
+exe 'profile start '.prof_fname
+profile! file ./**
+
+exe 'set runtimepath+='.expand(':h')
+
+set lazyredraw
+set noloadplugins
+set nomore
+set noswapfile
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..117d5673
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,49 @@
+from covimerage._compat import StringIO
+
+
+def test_build_vim_profile_args(devnull, tmpdir):
+ from covimerage.utils import build_vim_profile_args
+
+ F = build_vim_profile_args
+
+ fname = devnull.name
+ dname = str(tmpdir)
+
+ assert F(fname, [fname]) == [
+ '--cmd', 'profile start %s' % fname,
+ '--cmd', 'profile! file %s' % fname]
+ assert F(fname, [dname]) == [
+ '--cmd', 'profile start %s' % fname,
+ '--cmd', 'profile! file %s/*' % dname]
+ assert F(fname, [fname, dname]) == [
+ '--cmd', 'profile start %s' % fname,
+ '--cmd', 'profile! file %s' % fname,
+ '--cmd', 'profile! file %s/*' % dname]
+
+
+def test_get_fname_and_fobj_and_str(devnull):
+ from covimerage.utils import get_fname_and_fobj_and_str
+
+ F = get_fname_and_fobj_and_str
+ assert F('foo') == ('foo', None, 'foo')
+ assert F(None) == (None, None, 'None')
+ assert F(devnull) == ('/dev/null', devnull, '/dev/null')
+ s = StringIO('')
+ assert F(s) == (None, s, str(s))
+
+
+def test_is_executable_filename():
+ from covimerage.utils import is_executable_filename
+
+ assert is_executable_filename('foo.vim')
+ assert is_executable_filename('foo.nvim')
+ assert is_executable_filename('vimrc')
+ assert is_executable_filename('.vimrc')
+ assert is_executable_filename('minimal.vimrc')
+ assert is_executable_filename('another-minimal.vimrc')
+ assert is_executable_filename('another.minimal.vimrc')
+ assert is_executable_filename('foo.bar.vim')
+
+ assert not is_executable_filename('.hidden.vim')
+ assert not is_executable_filename('foo.txt')
+ assert not is_executable_filename('vim')
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..e73fc992
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,28 @@
+[tox]
+envlist = py{27,33,34,35,36}{,-dev}{,-coverage},checkqa
+
+[testenv]
+extras = {env:TOX_EXTRAS:testing}
+usedevelop = True
+passenv = PYTEST_ADDOPTS
+commands = {env:TOX_CMD:{env:COVERAGE_RUN:pytest}} {posargs}
+setenv =
+ coverage: COVERAGE_RUN=pytest --cov
+ dev: TOX_EXTRAS=testing,dev
+changedir =
+ integration: {envtmpdir}
+
+[testenv:checkqa]
+extras = qa
+commands =
+ flake8 --version
+ flake8 --show-source --statistics {posargs:covimerage tests}
+
+[testenv:coverage.pytest]
+passenv = COVERAGE_FILE
+commands = coverage run -m pytest {posargs}
+
+[testenv:coverage]
+commands =
+ {[testenv:coverage.pytest]commands}
+ coverage report -m