├── images ├── vroom_logo.png └── usage_screencast.gif ├── setup.py ├── MANIFEST.in ├── scripts ├── vroom ├── respond.vroomfaker └── shell.vroomfaker ├── .gitignore ├── .editorconfig ├── .github └── workflows │ └── discord.yml ├── examples ├── blocks.vroom ├── range.vroom ├── buffer.vroom ├── escaping.vroom ├── continuations.vroom ├── messages.vroom ├── mode.vroom ├── directives.vroom ├── controls.vroom ├── system.vroom ├── basics.vroom └── macros.vroom ├── pyproject.toml ├── vroom ├── result.py ├── environment.py ├── color.py ├── __init__.py ├── __main__.py ├── command.py ├── neovim_mod.py ├── test.py ├── buffer.py ├── messages.py ├── runner.py ├── args.py ├── shell.py ├── controls.py ├── actions.py ├── vim.py └── output.py ├── CONTRIBUTING.md ├── README.md └── LICENSE /images/vroom_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/vroom/HEAD/images/vroom_logo.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Distribution for vroom.""" 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /images/usage_screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/vroom/HEAD/images/usage_screencast.gif -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | exclude .git* 3 | 4 | graft examples/ 5 | graft images/ 6 | 7 | prune .git* 8 | -------------------------------------------------------------------------------- /scripts/vroom: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | import vroom.__main__ 6 | 7 | sys.exit(vroom.__main__.main()) 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /MANIFEST 3 | /build/ 4 | /deb_dist/ 5 | /dist/ 6 | /*.egg-info/ 7 | /.env/ 8 | /.venv/ 9 | /vroom/_version.py 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.{py,vroom}] 10 | indent_style = space 11 | indent_size = 2 12 | max_line_length = 80 13 | 14 | [*.py] 15 | quote_type = single 16 | -------------------------------------------------------------------------------- /.github/workflows/discord.yml: -------------------------------------------------------------------------------- 1 | name: notify-discord 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | issues: 9 | types: [opened] 10 | 11 | jobs: 12 | notify: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Actions for Discord 17 | env: 18 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 19 | uses: Ilshidur/action-discord@0.3.2 20 | -------------------------------------------------------------------------------- /examples/blocks.vroom: -------------------------------------------------------------------------------- 1 | % OneTwoThreeFour 2 | 3 | When you have a block of output commands, vroom checks them against the buffer 4 | sequentially: 5 | 6 | One 7 | Two 8 | Three 9 | 10 | When you have a line break between your output checks, you go back to the top. 11 | 12 | One 13 | 14 | One 15 | 16 | One 17 | Two 18 | Three 19 | 20 | Use two spaces instead of a blank line to check for an empty buffer line. (You 21 | may also use ` &` if you dislike trailing whitespace.) 22 | 23 | One 24 | Two 25 | Three 26 | & 27 | Four 28 | 29 | So if you want to comment during your outputting, comment without blank lines 30 | in-between. 31 | 32 | One 33 | Like this. 34 | Two 35 | 36 | Finally, three or more blank lines acts like a @clear. 37 | 38 | 39 | 40 | @end 41 | -------------------------------------------------------------------------------- /examples/range.vroom: -------------------------------------------------------------------------------- 1 | The range syntax is X,Y. Only the comma is required. If X is omitted, vroom 2 | picks up where it starts off. If Y is omitted, only one line is matched. 3 | 4 | If both are omitted, nothing really happens, but it's technically valid: 5 | 6 | % Hello, 7 | % World 8 | Hello, 9 | World (,) 10 | 11 | 12 | 13 | X may also be `.` to start the match at the current cursor position. 14 | 15 | % OneTwoTwoThreeThreeThree 16 | > 5k 17 | Two (.,+1) 18 | 19 | 20 | 21 | Y is the end of the range. Y may be absolute: 22 | 23 | > 10aHello 24 | > 10aWorld 25 | > dd 26 | Hello (1,10) 27 | World (,20) 28 | 29 | Or relative: 30 | 31 | Hello (1,10) 32 | World (,+9) 33 | 34 | Y may also be `$` to continue the check until the end of the buffer. 35 | 36 | Hello (1,10) 37 | World (,$) 38 | -------------------------------------------------------------------------------- /examples/buffer.vroom: -------------------------------------------------------------------------------- 1 | Buffer controls can be placed at the end of any output line. (See 2 | examples/controls.vroom for more on controls in general.) 3 | 4 | % Buffer one line one. 5 | % Buffer one line two. 6 | :vnew 7 | % Buffer two line one. 8 | % Buffer two line two. 9 | 10 | If you leave off the buffer control, vroom checks whatever buffer you've loaded 11 | last. If you haven't loaded a buffer before, it checks the active buffer. 12 | 13 | Buffer two line one. 14 | Buffer two line two. 15 | 16 | Otherwise, it loads the buffer you specify. 17 | 18 | Buffer one line one. (1) 19 | Buffer one line two. 20 | 21 | Notice how vroom sticks to the (1) buffer after you load it once. That lasts for 22 | continuous output lines. Whenever you start a new output block, vroom goes back 23 | to checking the active buffer. 24 | 25 | Buffer two line one. 26 | Buffer two line two. 27 | -------------------------------------------------------------------------------- /examples/escaping.vroom: -------------------------------------------------------------------------------- 1 | Sometimes, vroom syntax gets in the way of your tests. When you have weird 2 | tests, there's two escaping rules that you need to remember. 3 | 4 | 5 | 6 | 1. The ampersand (&) escapes a control block. 7 | 8 | % Literal line ending in (&1) 9 | Literal line ending in (&1) 10 | 11 | The contents of the buffer will be "Literal line ending in (1)" 12 | 13 | 14 | 15 | 2. Lines starting with ` & ` are also output lines. 16 | 17 | % > This looks like an input line. 18 | & > This looks like an input line. 19 | 20 | As a special case, you can use the line ` &` to match an empty buffer line. 21 | (two blank spaces will also work, but some editors complain about trailing 22 | whitespace.) 23 | 24 | @clear 25 | % HelloWorld 26 | Hello 27 | & 28 | World 29 | 30 | 31 | 32 | Finally, remember that you can escape special characters in glob mode with 33 | square brackets. 34 | 35 | % What? An asterisk* 36 | & ????[?] An *[*] (glob) 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools-scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "vim-vroom" 7 | authors = [{name = "Nate Soares", email = "nate@so8r.es"}] 8 | readme = "README.md" 9 | license = { text = "Apache 2.0" } 10 | description = "Launch your vimscript tests" 11 | requires-python = ">= 3.4" 12 | classifiers = [ 13 | "Development Status :: 3 - Alpha", 14 | "License :: OSI Approved :: Apache Software License", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.4", 17 | "Topic :: Software Development :: Testing", 18 | ] 19 | urls = { Repository = "https://github.com/google/vroom" } 20 | dynamic = ["version"] 21 | 22 | [project.optional-dependencies] 23 | neovim = ["neovim"] 24 | 25 | [tool.setuptools] 26 | packages = ["vroom"] 27 | script-files = [ 28 | "scripts/shell.vroomfaker", 29 | "scripts/respond.vroomfaker", 30 | "scripts/vroom", 31 | ] 32 | 33 | [tool.setuptools_scm] 34 | version_file = "vroom/_version.py" 35 | -------------------------------------------------------------------------------- /examples/continuations.vroom: -------------------------------------------------------------------------------- 1 | Sometimes you want lines that are longer that is sightful. For that, you want 2 | line continuations. 3 | 4 | Vroom line continuations are simple: they are appended to the line prior with no 5 | whitespace in between. 6 | 7 | > i 8 | |Hello 9 | Hello 10 | 11 | 12 | 13 | Line continuations can be chained and used anywhere. 14 | 15 | > i 16 | |H 17 | |e 18 | |l 19 | |l 20 | |o 21 | | 22 | 23 | 24 | 25 | They can't be given controls, so they're useful for outputting literal 26 | parentheses at the end of lines. 27 | 28 | > i 29 | |(regex) 30 | (regex) 31 | 32 | 33 | 34 | You can join output lines: 35 | 36 | > iHello 37 | Hel 38 | |lo 39 | 40 | 41 | 42 | And system hijack lines: 43 | 44 | :.!echo "Hello" 45 | $ Capt 46 | |ured 47 | Captured 48 | 49 | Though remember that you can string system hijack lines together to get 50 | newlines, which is more often what you want with system hijacking: 51 | 52 | :.!echo "Hello" 53 | $ Captured, my friend. 54 | $ Captured. 55 | -------------------------------------------------------------------------------- /examples/messages.vroom: -------------------------------------------------------------------------------- 1 | You can check message output with a ` ~ ` line. You must catch messages after 2 | the command that causes them if you want it to work. 3 | 4 | :echomsg "Hello." 5 | ~ Hello. 6 | 7 | This mechanism allows vroom to know what lines errors occur on. You can match 8 | multiple messages on one command. 9 | 10 | :set cmdheight=99 11 | 12 | :echomsg "First" | echomsg "Second" 13 | ~ First 14 | ~ Second 15 | 16 | Note sometimes multiple lines of output cause vroom to get stuck at a "Hit ENTER 17 | to continue" prompt (typically in Neovim mode), and increasing cmdheight is a 18 | simple workaround to avoid getting stuck. See 19 | https://github.com/google/vroom/issues/83. 20 | 21 | By default, vroom doesn't mind if you miss messages. It only minds if you miss 22 | messages that vroom thinks look like error messages. 23 | 24 | You can tweak this behavior with the --message-strictness flag. 25 | 26 | For example, the test below will fail with --message-strictness=STRICT, because 27 | the message is not caught. 28 | 29 | :echomsg "Third." 30 | -------------------------------------------------------------------------------- /examples/mode.vroom: -------------------------------------------------------------------------------- 1 | By default, output lines are matched in verbatim mode: 2 | 3 | % The quick brown fox jumps over the lazy dog? 4 | The quick brown fox jumps over the lazy dog? 5 | 6 | You make modes explicit with control blocks: 7 | 8 | The quick brown fox jumps over the lazy dog? (verbatim) 9 | 10 | You may also use `glob` mode and `regex` mode. * and ? are expanded in glob mode 11 | and may be escaped. This works like python's fnmatch. 12 | 13 | The * * ??? jumps over the * dog[?] (glob) 14 | 15 | Regexes are python-style. 16 | 17 | \w+ \w+ brown fox .* lazy dog\? (regex) 18 | 19 | Python regex flags etc. all work: 20 | 21 | (?i)\w+ \w+ BROWN FOX .* LAZY DOG. (regex) 22 | 23 | We suggest you use flags at the beginning of the regex, so as to not confuse 24 | vroom into thinking you're misspelling line controls. 25 | 26 | 27 | 28 | System capture lines are matched in regex mode by default: 29 | 30 | :.!echo 'Hello.' 31 | ! echo .* 32 | Hello. 33 | 34 | You may override that. 35 | 36 | @clear 37 | :.!echo '.*' 38 | ! echo '.*' (verbatim) 39 | .* 40 | -------------------------------------------------------------------------------- /vroom/result.py: -------------------------------------------------------------------------------- 1 | """Result type.""" 2 | 3 | from collections import namedtuple 4 | from enum import Enum 5 | 6 | class ResultType(Enum): 7 | result = 1 8 | error = 2 9 | 10 | # Inherit from namedtuple so we get an immutable value. 11 | class Result(namedtuple('Result', ['status', 'value'])): 12 | """Holds the result or error of a function call. 13 | 14 | status should be one of ResultType.result or ResultType.error 15 | """ 16 | 17 | @classmethod 18 | def Result(cls, value): 19 | return super(Result, cls).__new__( 20 | cls, status=ResultType.result, value=value) 21 | 22 | @classmethod 23 | def Error(cls, value): 24 | return super(Result, cls).__new__(cls, status=ResultType.error, value=value) 25 | 26 | @classmethod 27 | def Success(cls): 28 | """Used to indicate success when the actual value is irrelevant.""" 29 | return super(Result, cls).__new__(cls, status=ResultType.result, value=True) 30 | 31 | def IsError(self): 32 | return self.status is ResultType.error 33 | 34 | def IsSignificant(self): 35 | if self.status is ResultType.result: 36 | return False 37 | return self.value.IsSignificant() 38 | -------------------------------------------------------------------------------- /vroom/environment.py: -------------------------------------------------------------------------------- 1 | """A vroom test execution environment. 2 | 3 | This is an object with all of the vroom verifiers asked. Good for one file. 4 | """ 5 | import vroom.buffer 6 | import vroom.messages 7 | import vroom.output 8 | import vroom.shell 9 | import vroom.vim 10 | 11 | 12 | class Environment(object): 13 | """The environment object. 14 | 15 | Sets up all the verifiers and managers and communicators you'll ever need. 16 | """ 17 | 18 | def __init__(self, filename, args): 19 | self.args = args 20 | self.message_strictness = args.message_strictness 21 | self.system_strictness = args.system_strictness 22 | self.filename = filename 23 | self.writer = vroom.output.Writer(filename, args) 24 | self.shell = vroom.shell.Communicator(filename, self, self.writer) 25 | if args.neovim: 26 | import vroom.neovim_mod as neovim_mod 27 | self.vim = neovim_mod.Communicator(args, self.shell.env, self.writer) 28 | else: 29 | self.vim = vroom.vim.Communicator(args, self.shell.env, self.writer) 30 | self.buffer = vroom.buffer.Manager(self.vim) 31 | self.messenger = vroom.messages.Messenger(self.vim, self, self.writer) 32 | -------------------------------------------------------------------------------- /scripts/respond.vroomfaker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | A special program which fakes a shell script. 4 | Given a json specification containing any/all of: 5 | { 6 | 'command': ['commands', 'to', 'run'], 7 | 'stdout': ['output', 'lines', 'to', 'print'], 8 | 'stderr': ['error', 'lines', 'to', 'print'], 9 | 'status': status-number, 10 | } 11 | It will call the command, print the output, and exit with the status. 12 | Useful for test frameworks (read: vroom) who want to hijack system calls during 13 | test runs. 14 | 15 | Cannot be rolled into shell.vroomfaker because vim may instruct the shell to 16 | rediect output to various places: the fake shell needs a command that it can 17 | tell vim to run. 18 | """ 19 | import sys 20 | import json 21 | import subprocess 22 | 23 | from vroom.shell import OUTCHANNEL 24 | 25 | 26 | if len(sys.argv) != 2: # program & json 27 | sys.stderr.write('Please call this with one json-serialized control.\n') 28 | sys.exit(1) 29 | 30 | 31 | control = json.loads(sys.argv[1]) 32 | status = 0 33 | for command in control.get(OUTCHANNEL.COMMAND, []): 34 | # If they give multiple commands, we continue executing until we've done them 35 | # all or until one of them has a non-zero exit status. 36 | status = status or subprocess.call(command, shell=True) 37 | if OUTCHANNEL.STDOUT in control: 38 | sys.stdout.write('\n'.join(control[OUTCHANNEL.STDOUT])) 39 | if OUTCHANNEL.STDERR in control: 40 | sys.stderr.write('\n'.join(control[OUTCHANNEL.STDERR])) 41 | sys.exit(control.get(OUTCHANNEL.STATUS, status)) 42 | -------------------------------------------------------------------------------- /examples/directives.vroom: -------------------------------------------------------------------------------- 1 | Lines that start with ` @` are directives. Some are hard-baked shortcuts, 2 | others unleash special functionality that can't be achieved otherwise. 3 | 4 | Vroom directives include: 5 | 6 | 1. @end, used to check that all output has been accounted for. 7 | 8 | > 10aHellodd 9 | 10 | Normally, vroom checks the lines that you tell it to check and no further. Using 11 | the @end directive, you can verify that the last line checked was the final line 12 | in the buffer. 13 | 14 | Hello (,+9) 15 | @end 16 | 17 | 18 | 19 | 2. @clear, which clears out all the existing buffers. Use it between tests when 20 | you want to start anew. 21 | 22 | % Hello 23 | @clear 24 | @end 25 | 26 | 27 | 28 | 3. @messages, which can control the message strictness temporarily. 29 | 30 | Normally, you *must* catch error messages, or vroom complains (unless you've 31 | tweaked the --message-strictness flag). 32 | 33 | :set cmdheight=99 34 | 35 | :throw 'I broke something!' 36 | ~ Error detected while processing * (glob) 37 | ~ E605: Exception not caught: * (glob) 38 | 39 | But with the @messages directive, you can temporarily change the message 40 | strictness setting: 41 | 42 | @messages (RELAXED) 43 | :throw 'I broke it again.' 44 | @messages 45 | 46 | Notice how @messages without a control block resets the message handling back 47 | to its original --message-strictness setting. 48 | 49 | 50 | 51 | You may also use @system to temporarily override --system-strictness. 52 | 53 | @system (RELAXED) 54 | :.!echo "Hello" 55 | @system 56 | 57 | This will pass even if you run the test with --system-strictness=STRICT 58 | -------------------------------------------------------------------------------- /vroom/color.py: -------------------------------------------------------------------------------- 1 | """Vroom terminal coloring.""" 2 | import subprocess 3 | 4 | # Grab the colors from the system. 5 | try: 6 | BOLD = subprocess.check_output(['tput', 'bold']).decode('utf-8') 7 | RED = subprocess.check_output(['tput', 'setaf', '1']).decode('utf-8') 8 | GREEN = subprocess.check_output(['tput', 'setaf', '2']).decode('utf-8') 9 | YELLOW = subprocess.check_output(['tput', 'setaf', '3']).decode('utf-8') 10 | BLUE = subprocess.check_output(['tput', 'setaf', '4']).decode('utf-8') 11 | VIOLET = subprocess.check_output(['tput', 'setaf', '5']).decode('utf-8') 12 | TEAL = subprocess.check_output(['tput', 'setaf', '6']).decode('utf-8') 13 | WHITE = subprocess.check_output(['tput', 'setaf', '7']).decode('utf-8') 14 | BLACK = subprocess.check_output(['tput', 'setaf', '8']).decode('utf-8') 15 | RESET = subprocess.check_output(['tput', 'sgr0']).decode('utf-8') 16 | except subprocess.CalledProcessError: 17 | COLORED = False 18 | # Placeholders for code that tries to map things onto terminal colors. 19 | # These will be unused anyway if COLORED=False, and empty string would be 20 | # "no-op" color code for any code that did end up using these values. 21 | BOLD = '' 22 | RED = '' 23 | GREEN = '' 24 | YELLOW = '' 25 | BLUE = '' 26 | VIOLET = '' 27 | TEAL = '' 28 | WHITE = '' 29 | BLACK = '' 30 | RESET = '' 31 | else: 32 | COLORED = True 33 | 34 | 35 | # We keep the unused argument for symmetry with Colored 36 | def Colorless(text, *escapes): # pylint: disable-msg=unused-argument 37 | """Idempotent. 38 | 39 | Args: 40 | text: The text to color. 41 | *escapes: Ignored. 42 | Returns: 43 | text 44 | """ 45 | return text 46 | 47 | 48 | def Colored(text, *escapes): 49 | """Prints terminal color escapes around the text. 50 | 51 | Args: 52 | text: The text to color. 53 | *escapes: The terminal colors to print. 54 | Returns: 55 | text surrounded by the right escapes. 56 | """ 57 | if not COLORED: 58 | return text 59 | return '%s%s%s' % (''.join(escapes), text, RESET) 60 | -------------------------------------------------------------------------------- /vroom/__init__.py: -------------------------------------------------------------------------------- 1 | """Patterns common to all vroom components.""" 2 | import sys 3 | 4 | try: 5 | from ._version import __version__ as __version__ 6 | except ImportError: 7 | import warnings 8 | warnings.warn('Failed to load __version__ from setuptools-scm') 9 | __version__ = '__unknown__' 10 | 11 | 12 | # Don't even try to run under python 2 or earlier. It will seem to work but fail 13 | # in corner cases with strange encoding errors. 14 | if sys.version_info[0] < 3: 15 | raise ImportError('Python < 3 is unsupported') 16 | 17 | 18 | def Specification(*numbered, **named): 19 | """Creates a specification type, useful for defining constants. 20 | 21 | >>> Animal = Specification('PIG', 'COW') 22 | >>> Animal.PIG 23 | 0 24 | >>> Animal.COW 25 | 1 26 | >>> Animal.Lookup(1) 27 | 'COW' 28 | 29 | >>> Animal = Specification(PIG='pig', COW='cow') 30 | >>> Animal.PIG 31 | 'pig' 32 | >>> Animal.COW 33 | 'cow' 34 | >>> tuple(sorted(Animal.Fields())) 35 | ('COW', 'PIG') 36 | >>> tuple(sorted(Animal.Values())) 37 | ('cow', 'pig') 38 | 39 | Args: 40 | *numbered: A list of fields (zero-indexed) that make up the spec. 41 | **named: A dict of fields to make up the spec. 42 | Returns: 43 | A 'Specification' type with the defined fields. It also has these methods: 44 | Lookup: Given a value, try to find a field with that value. 45 | Fields: Returns an iterable of all the fields. 46 | Values: Returns an iterable of all the values. 47 | """ 48 | enum = dict({n: i for i, n in enumerate(numbered)}, **named) 49 | inverted = dict(zip(enum.values(), enum.keys())) 50 | data = dict(enum) 51 | data['Lookup'] = inverted.get 52 | data['Fields'] = enum.keys 53 | data['Values'] = enum.values 54 | return type('Specification', (), data) 55 | 56 | 57 | class ParseError(Exception): 58 | """For trouble when parsing vroom scripts.""" 59 | 60 | def __init__(self, *args, **kwargs): 61 | self.lineno = None 62 | super(ParseError, self).__init__(*args, **kwargs) 63 | 64 | def SetLineNumber(self, lineno): 65 | self.lineno = lineno 66 | 67 | 68 | class ConfigurationError(Exception): 69 | """For improperly configured vroom scripts, syntax nonwithstanding.""" 70 | -------------------------------------------------------------------------------- /examples/controls.vroom: -------------------------------------------------------------------------------- 1 | When you end a line with a space and parentheses, you're sending controls to 2 | vroom. (If you'd rather not, see examples/escaping.vroom.) Different types of 3 | lines take different types of controls. 4 | 5 | The most important controls are output line controls. There are three different 6 | controls words that you can use in output lines: 7 | 8 | 9 | 10 | 1. You can choose an output buffer. Buffer controls are just a number. 11 | 12 | % Buffer one. 13 | :vnew 14 | % Buffer two. 15 | Buffer one. (1) 16 | Buffer two. (2) 17 | 18 | See examples/buffer.vroom for more tricks. 19 | 20 | 21 | 22 | 2. You may select a range of lines to match in the current buffer. You do this 23 | by specifying X,Y where X is the line to start on and Y is the line to end on. 24 | 25 | > 10aHello 26 | > 10aWorld 27 | > dd 28 | Hello (1,10) 29 | World (,$) 30 | 31 | 32 | 33 | 2.1. A slight variation on the range control, you may use the `.` control to 34 | check starting at the cursor line instead of starting at the top of the buffer. 35 | 36 | % OneTwoThree 37 | > k 38 | Two (.) 39 | 40 | 41 | 42 | 3. You may change the match mode of the output line to `regex` or `glob`. 43 | 44 | % The quick brown fox jumps over the lazy dog? 45 | The * * ??? jumps over the * dog[?] (glob) 46 | 47 | See examples/mode.vroom for more tricks. 48 | 49 | 50 | 51 | Message capture lines can specify a mode, but no other controls. 52 | 53 | :echomsg "Hello there!" 54 | ~ * (glob) 55 | :echomsg "Hello there!" 56 | ~ .* (regex) 57 | :echomsg "Hello there!" 58 | ~ Hello there! 59 | 60 | System capture lines are similar: you may specify a mode, but nothing else. 61 | 62 | 63 | 64 | Input lines also have their own special type of control, the delay control. You 65 | use this to have vroom pause after a command that is going to take a long time. 66 | 67 | :sleep 1 (1.1s) 68 | % I'm awake now! 69 | I'm awake now! 70 | 71 | The delay control is any number (decimal allowed) followed by an 's'. Vroom 72 | pauses for that many seconds before continuing. The delay must be prefixed with 73 | a leading zero if it's less than one. 74 | 75 | Note that this pause is in addition to the delay specified by the -d flag. 76 | -------------------------------------------------------------------------------- /vroom/__main__.py: -------------------------------------------------------------------------------- 1 | """The vroom test runner.""" 2 | import os 3 | import signal 4 | import subprocess 5 | import sys 6 | 7 | import vroom.args 8 | import vroom.color 9 | import vroom.output 10 | import vroom.runner 11 | import vroom.vim 12 | 13 | 14 | def main(argv=None): 15 | if argv is None: 16 | argv = sys.argv 17 | 18 | try: 19 | args = vroom.args.Parse(argv[1:]) 20 | except ValueError as e: 21 | sys.stderr.write('%s\n' % ', '.join(e.args)) 22 | return 1 23 | 24 | if args.murder: 25 | try: 26 | output = subprocess.check_output(['ps', '-A']).decode('utf-8') 27 | except subprocess.CalledProcessError: 28 | sys.stdout.write("Can't find running processes.\n") 29 | return 1 30 | for line in output.splitlines(): 31 | if line.endswith('vroom'): 32 | pid = int(line.split(None, 1)[0]) 33 | # ARE YOU SUICIDAL?! 34 | if pid != os.getpid(): 35 | sys.stdout.write('Killing a vroom: %s\n' % line) 36 | os.kill(pid, signal.SIGKILL) 37 | break 38 | else: 39 | sys.stdout.write('No running vrooms found.\n') 40 | return 0 41 | end = 'VroomEnd()' 42 | kill = ['vim', '--servername', args.servername, '--remote-expr', end] 43 | sys.stdout.write("I hope you're happy.\n") 44 | return subprocess.call(kill) 45 | 46 | dirty = False 47 | writers = [] 48 | try: 49 | for filename in args.filenames: 50 | with open(filename) as f: 51 | runner = vroom.runner.Vroom(filename, args) 52 | writers.append(runner(f)) 53 | if runner.dirty: 54 | dirty = True 55 | except vroom.vim.ServerQuit as e: 56 | # If the vim server process fails, the details are probably on stderr, so hope 57 | # for the best and exit without shell reset. 58 | sys.stderr.write('Exception: {}\n'.format(e)) 59 | return 2 60 | 61 | if dirty: 62 | # Running vim in a process can screw with shell line endings. Reset terminal. 63 | subprocess.call(['reset']) 64 | 65 | for writer in writers: 66 | writer.Write() 67 | 68 | vroom.output.WriteBackmatter(writers, args) 69 | 70 | failed_tests = [w for w in writers if w.Status() != vroom.output.STATUS.PASS] 71 | if failed_tests: 72 | return 3 73 | 74 | 75 | if __name__ == '__main__': 76 | sys.exit(main()) 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributor License Agreement ## 2 | 3 | Patches and contributions are welcome. Before we can accept them, though, we 4 | have to see to the legal details. 5 | 6 | Contributions to any Google project must be accompanied by a Contributor 7 | License Agreement. This is not a copyright **assignment**, it simply gives 8 | Google permission to use and redistribute your contributions as part of the 9 | project. 10 | 11 | * If you are an individual writing original source code and you're sure you 12 | own the intellectual property, then you'll need to sign an [individual 13 | CLA][]. 14 | 15 | * If you work for a company that wants to allow you to contribute your work, 16 | then you'll need to sign a [corporate CLA][]. 17 | 18 | You generally only need to submit a CLA once, so if you've already submitted 19 | one (even if it was for a different project), you probably don't need to do it 20 | again. 21 | 22 | [individual CLA]: https://developers.google.com/open-source/cla/individual 23 | [corporate CLA]: https://developers.google.com/open-source/cla/corporate 24 | 25 | 26 | ## Submitting a patch ## 27 | 28 | 1. It's generally best to start by opening a new issue describing the bug or 29 | feature you're intending to fix. Even if you think it's relatively minor, 30 | it's helpful to know what people are working on. Mention in the initial 31 | issue that you are planning to work on that bug or feature so that it can 32 | be assigned to you. 33 | 34 | 1. Follow the normal process of [forking][] the project, and setup a new 35 | branch to work in. It's important that each group of changes be done in 36 | separate branches in order to ensure that a pull request only includes the 37 | commits related to that bug or feature. 38 | 39 | 1. Any significant changes should almost always be accompanied by tests. The 40 | project already has good test coverage, so look at some of the existing 41 | tests (in the `vroom/` directory) if you're unsure how to go about it. 42 | 43 | 1. Do your best to have [well-formed commit messages][] for each change. 44 | This provides consistency throughout the project, and ensures that commit 45 | messages are able to be formatted properly by various git tools. 46 | 47 | 1. Finally, push the commits to your fork and submit a [pull request][]. 48 | 49 | [forking]: https://help.github.com/articles/fork-a-repo 50 | [well-formed commit messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 51 | [pull request]: https://help.github.com/articles/creating-a-pull-request 52 | -------------------------------------------------------------------------------- /examples/system.vroom: -------------------------------------------------------------------------------- 1 | Sometimes, vim makes calls to the system at large. That could be bad during 2 | testing if your plugin is hitting a network or launching the nukes. 3 | 4 | Vroom provides you with two tools to handle vim system calls. 5 | 6 | Firstly, you can verify that system calls match a certain expected pattern: 7 | 8 | :.!echo "Hey system, talk to me." 9 | ! echo .* 10 | 11 | System calls are matched in regex mode by default. 12 | 13 | 14 | 15 | This works with :call as well. 16 | 17 | :echomsg system('echo hi') 18 | ! echo hi (verbatim) 19 | ~ hi 20 | 21 | 22 | 23 | If the system call that vim actually makes doesn't match the one you specify, 24 | vroom will complain. 25 | 26 | By default, any unexpected system calls will trigger a failure. This strictness 27 | helps protect you from unknowingly triggering system calls that succeed in your 28 | environment but fail when others try to run them, and also encourages you to 29 | document your expectations. But if this gets annoying, you can always turn it 30 | off with @system (RELAXED). 31 | 32 | @system (RELAXED) 33 | :.!echo "Come at me, bro." 34 | 35 | 36 | This is nice, but it doesn't really give you much control over the system. To do 37 | that, you need to hijack the system and hoodwink vim: 38 | 39 | :.!echo "Hey system, talk to me." 40 | ! echo "Hey system, talk to me\." 41 | $ No. I'm not talking. 42 | 43 | Let's check whether or not that worked: 44 | 45 | No. I'm not talking. 46 | 47 | Good! 48 | 49 | 50 | 51 | If you need newlines in your dummy output, string hijack commands together. 52 | 53 | :.!echo "Please?" 54 | ! echo "Please\?" 55 | $ No! 56 | $ Go away. 57 | No! 58 | Go away. 59 | 60 | 61 | 62 | You may use system hijack lines to change the exit status using the "status" 63 | control word: 64 | 65 | :set cmdheight=99 66 | 67 | :.!echo "Testing" 68 | $ 2 (status) 69 | :echomsg v:shell_error 70 | ~ 2 71 | 72 | 73 | Or even to change the command entirely: 74 | 75 | :.!rm "Testing." 76 | $ echo "Testing." (command) 77 | Testing. 78 | 79 | The default output channel is 'stdout'. You may explicitly mark it if you wish. 80 | 81 | :.!echo "Testing" 82 | $ .gnitseT (stdout) 83 | .gnitseT 84 | 85 | 86 | 87 | If you capture a command with a regex, the regex's match groups will be 88 | available to hijack lines. 89 | 90 | :.!rm hello 91 | ! rm (\w+) 92 | $ echo \1 (command) 93 | hello 94 | 95 | 96 | 97 | :.!rm hello 98 | ! rm (\w+) 99 | $ \1 \1 100 | hello hello 101 | 102 | @system 103 | -------------------------------------------------------------------------------- /vroom/command.py: -------------------------------------------------------------------------------- 1 | """Vroom command blocks. 2 | 3 | Vroom actions are written (by the user) out of order (from vroom's perspective). 4 | Consider the following: 5 | 6 | :.!echo "Hi" 7 | $ Bye 8 | 9 | Vroom must hijack the shell before the command is ever executed if the fake 10 | shell is to know that to do when the system call comes down the line. 11 | 12 | Thus, we need a Command object which is the combination of a command and all of 13 | the checks and responses attached to it. 14 | """ 15 | import vroom.test 16 | 17 | from vroom.result import Result 18 | 19 | class Command(object): 20 | """Holds a vim command and records all checks requiring verification.""" 21 | 22 | def __init__(self, command, lineno, delay, env): 23 | self.lineno = lineno 24 | self.env = env 25 | self.command = command 26 | self.delay = delay 27 | self.fakecmd = env.args.responder 28 | self._mexpectations = [] 29 | self._syspectations = [] 30 | 31 | def ExpectMessage(self, message, mode): 32 | self._mexpectations.append((message, mode)) 33 | 34 | def ExpectSyscall(self, syscall, mode): 35 | if self._syspectations: 36 | self._syspectations[-1].closed = True 37 | self._syspectations.append(vroom.shell.Hijack(self.fakecmd, syscall, mode)) 38 | 39 | def RespondToSyscall(self, response, **controls): 40 | if not self._syspectations or self._syspectations[-1].closed: 41 | self._syspectations.append(vroom.shell.Hijack(self.fakecmd)) 42 | self._syspectations[-1].Respond(response, **controls) 43 | 44 | def LineBreak(self): 45 | if self._syspectations: 46 | self._syspectations[-1].closed = True 47 | 48 | def Execute(self): 49 | """Executes the command and verifies all checks.""" 50 | if not any((self.command, self._mexpectations, self._syspectations)): 51 | return Result.Success() 52 | 53 | self.env.shell.Control(self._syspectations) 54 | oldmessages = self.env.vim.GetMessages() 55 | if self.lineno: 56 | self.env.writer.actions.ExecutedUpTo(self.lineno) 57 | if self.command: 58 | delay = self.delay 59 | if self._syspectations: 60 | delay += self.env.args.shell_delay 61 | self.env.vim.Communicate(self.command, delay) 62 | 63 | failures = [] 64 | # Verify the message list. 65 | newmessages = self.env.vim.GetMessages() 66 | result = self.env.messenger.Verify( 67 | oldmessages, newmessages, self._mexpectations) 68 | if result.IsError(): 69 | failures.append(result.value) 70 | 71 | # Verify the shell. 72 | result = self.env.shell.Verify() 73 | if result.IsError(): 74 | failures.append(result.value) 75 | 76 | if failures: 77 | return Result.Error(vroom.test.Failures(failures)) 78 | else: 79 | return Result.Success() 80 | -------------------------------------------------------------------------------- /scripts/shell.vroomfaker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """A fake shell, used by vroom to capture and control vim system calls. 3 | 4 | This executable is run once per shell command, and thus must do all of its 5 | persistent communication via temporary files. 6 | 7 | This executable must be run in a modified environment that tells it where to 8 | write its persistent data. 9 | """ 10 | import os 11 | import pickle 12 | import subprocess 13 | import sys 14 | 15 | import vroom.shell 16 | import vroom.test 17 | import vroom.vim 18 | 19 | logfile = os.environ.get(vroom.shell.LOG_FILENAME_VAR) 20 | controlfile = os.environ.get(vroom.shell.CONTROL_FILENAME_VAR) 21 | errorfile = os.environ.get(vroom.shell.ERROR_FILENAME_VAR) 22 | 23 | try: 24 | # Vim will always call this as "/path/to/$SHELL -c 'command'" 25 | if len(sys.argv) < 3: 26 | sys.stderr.write( 27 | 'Wrong number of arguments. ' 28 | 'Please use this fake shell for vim testing only.\n') 29 | sys.exit(1) 30 | 31 | # Make sure the environment is tweaked in our favor. 32 | if not all((logfile, controlfile, errorfile)): 33 | sys.stderr.write('Expected environment modifications not found.\n') 34 | sys.stderr.write('Please only use this shell in a vroom environment.\n') 35 | sys.exit(1) 36 | 37 | # Load the files. 38 | with open(logfile, 'rb') as f: 39 | logs = pickle.load(f) 40 | with open(controlfile, 'rb') as f: 41 | controls = pickle.load(f) 42 | 43 | # Parse the user command out from vim's gibberish. 44 | command, rebuild = vroom.vim.SplitCommand(sys.argv[2]) 45 | logs.append(vroom.test.Received(command)) 46 | handled = False 47 | 48 | # Consume a control if it matches a vroom system action. 49 | if len(controls): 50 | hijack = controls[0] 51 | response = hijack.Response(command) 52 | if response is not False: # The hijack matches. 53 | if hijack.expectation is not None: # It was picky. 54 | logs.append(vroom.test.Matched(hijack.expectation, hijack.mode)) 55 | logs.append(vroom.test.Responded(response)) 56 | command = response 57 | handled = True 58 | controls = controls[1:] 59 | 60 | # Check if the command was RECEIVED but not dealt with. 61 | if not handled: 62 | logs.append(vroom.test.Unexpected()) 63 | 64 | # Update the files. 65 | with open(controlfile, 'wb') as f: 66 | pickle.dump(controls, f) 67 | with open(logfile, 'wb') as f: 68 | pickle.dump(logs, f) 69 | 70 | # Send the call through to the system. 71 | shell = os.environ['SHELL'] 72 | status = subprocess.call(rebuild(command), executable=shell, shell=True) 73 | 74 | except Exception as e: 75 | # One hopes that the following contains no errors. 76 | with open(errorfile, 'rb') as f: 77 | errors = pickle.load(f) 78 | errors.append(vroom.test.ErrorLog(*sys.exc_info())) 79 | with open(errorfile, 'wb') as f: 80 | pickle.dump(errors, f) 81 | sys.exit(1) 82 | 83 | sys.exit(status) 84 | -------------------------------------------------------------------------------- /vroom/neovim_mod.py: -------------------------------------------------------------------------------- 1 | from vroom.vim import CONFIGFILE 2 | from vroom.vim import Communicator as VimCommunicator 3 | import subprocess 4 | import tempfile 5 | import time 6 | import neovim 7 | import os 8 | 9 | class Communicator(VimCommunicator): 10 | """Object to communicate with a Neovim server.""" 11 | 12 | _listen_addr = None 13 | 14 | def __init__(self, args, env, writer): 15 | self.writer = writer.commands 16 | self.args = args 17 | self.start_command = [ 18 | 'nvim', 19 | '-u', args.vimrc, 20 | '-c', 'set shell=' + args.shell, 21 | '-c', 'source %s' % CONFIGFILE] 22 | self.env = env 23 | self._cache = {} 24 | 25 | def Quit(self): 26 | if not hasattr(self, 'nvim'): 27 | # Never started 28 | return 29 | 30 | self.nvim.quit() 31 | 32 | def Start(self): 33 | """Starts Neovim""" 34 | if self._listen_addr is not None: 35 | raise InvocationError('Called Start on already-running neovim instance') 36 | tmpdir = tempfile.mkdtemp() 37 | self._listen_addr = os.path.join(tmpdir, 'nvim.pipe') 38 | # Legacy env var, used by nvim <0.8 39 | self.env['NVIM_LISTEN_ADDRESS'] = self._listen_addr 40 | self.start_command += ['--listen', self._listen_addr] 41 | 42 | self.process = subprocess.Popen(self.start_command, env=self.env) 43 | start_time = time.time() 44 | # Wait at most 5s for the Neovim socket 45 | while not os.path.exists(self._listen_addr) \ 46 | and time.time() - start_time < 5: 47 | time.sleep(0.01) 48 | self.nvim = neovim.attach('socket', path=self._listen_addr) 49 | 50 | def Communicate(self, command, extra_delay=0): 51 | """Sends a command to Neovim. 52 | 53 | Blocks forever if remote nvim quit unexpectedly. 54 | 55 | Args: 56 | command: The command to send. 57 | extra_delay: Delay in excess of --delay 58 | """ 59 | self.writer.Log(command) 60 | parsed_command = self.nvim.replace_termcodes(command, True, True, True) 61 | self.nvim.feedkeys(parsed_command) 62 | self._cache = {} 63 | time.sleep(self.args.delay + extra_delay) 64 | 65 | def Ask(self, expression): 66 | """Asks vim for the result of an expression. 67 | 68 | Blocks forever if remote nvim quit unexpectedly. 69 | 70 | Args: 71 | expression: The expression to ask for. 72 | Returns: 73 | Return value from vim. 74 | """ 75 | return self.nvim.eval(expression) 76 | 77 | def GetBufferLines(self, number): 78 | """Gets the lines in the requested buffer. 79 | 80 | Args: 81 | number: The buffer number to load. SHOULD NOT be a member of 82 | SpecialBuffer, use GetMessages if you want messages. Only works on 83 | real buffers. 84 | Returns: 85 | The buffer lines. 86 | """ 87 | if number not in self._cache: 88 | if number is None: 89 | buf = self.nvim.current.buffer 90 | else: 91 | for b in self.nvim.buffers: 92 | if b.number == number: 93 | buf = b 94 | break 95 | 96 | self._cache[number] = list(buf) 97 | return self._cache[number] 98 | 99 | def GetCurrentLine(self): 100 | """Figures out what line the cursor is on. 101 | 102 | Returns: 103 | The cursor's line. 104 | """ 105 | if 'line' not in self._cache: 106 | lineno = self.nvim.current.window.cursor[0] 107 | self._cache['line'] = int(lineno) 108 | return self._cache['line'] 109 | 110 | def Kill(self): 111 | """Kills the Neovim process and removes the socket""" 112 | VimCommunicator.Kill(self) 113 | 114 | if os.path.exists(self._listen_addr): 115 | os.remove(self._listen_addr) 116 | 117 | 118 | class InvocationError(Exception): 119 | """Raised when there's a problem starting or interacting with neovim instance. 120 | """ 121 | is_fatal = True 122 | 123 | -------------------------------------------------------------------------------- /vroom/test.py: -------------------------------------------------------------------------------- 1 | """Vroom test utilities.""" 2 | import fnmatch 3 | import re 4 | import traceback 5 | 6 | import vroom 7 | import vroom.controls 8 | 9 | RESULT = vroom.Specification( 10 | PASSED='passed', 11 | ERROR='error', 12 | FAILED='failed', 13 | SENT='sent') 14 | 15 | LOG = vroom.Specification( 16 | RECEIVED='received', 17 | MATCHED='matched', 18 | RESPONDED='responded', 19 | UNEXPECTED='unexpected', 20 | ERROR='error') 21 | 22 | 23 | def IsBad(result): 24 | """Whether or not a result is something to worry about. 25 | 26 | >>> IsBad(RESULT.PASSED) 27 | False 28 | >>> IsBad(RESULT.FAILED) 29 | True 30 | 31 | Args: 32 | result: The RESULT. 33 | Returns: 34 | Whether the result is bad. 35 | """ 36 | return result in (RESULT.ERROR, RESULT.FAILED) 37 | 38 | 39 | def Matches(request, mode, data): 40 | """Checks whether data matches the requested string under the given mode. 41 | 42 | >>> sentence = 'The quick brown fox jumped over the lazy dog.' 43 | >>> Matches(sentence, vroom.controls.MODE.VERBATIM, sentence) 44 | True 45 | >>> Matches('The * * fox * * the ???? *', vroom.controls.MODE.GLOB, sentence) 46 | True 47 | >>> Matches('The quick .*', vroom.controls.MODE.REGEX, sentence) 48 | True 49 | >>> Matches('Thy quick .*', vroom.controls.MODE.REGEX, sentence) 50 | False 51 | 52 | Args: 53 | request: The requested string (likely a line in a vroom file) 54 | mode: The match mode (regex|glob|verbatim) 55 | data: The data to verify 56 | Returns: 57 | Whether or not the data checks out. 58 | """ 59 | if mode is None: 60 | mode = vroom.controls.DEFAULT_MODE 61 | if mode == vroom.controls.MODE.VERBATIM: 62 | return request == data 63 | elif mode == vroom.controls.MODE.GLOB: 64 | return fnmatch.fnmatch(data, request) 65 | else: 66 | return re.match(request + '$', data) is not None 67 | 68 | 69 | class Failure(Exception): 70 | """Raised when a test fails.""" 71 | 72 | def IsSignificant(self): 73 | """Whether this failure is significant enough to report on its own. 74 | 75 | Some failures are insignificant failures that should only be shown along 76 | with significant errors to help troubleshoot. Example: Unexpected messages 77 | in RELAXED mode aren't significant.""" 78 | return True 79 | 80 | 81 | class Failures(Failure): 82 | """Raised when multiple Failures occur.""" 83 | 84 | def __init__(self, failures): 85 | super(Failures, self).__init__() 86 | self.failures = failures 87 | 88 | def GetFlattenedFailures(self): 89 | flattened_failures = [] 90 | for f in self.failures: 91 | if hasattr(f, 'GetFlattenedFailures'): 92 | flattened_failures.extend(f.GetFlattenedFailures()) 93 | else: 94 | flattened_failures.append(f) 95 | return flattened_failures 96 | 97 | def IsSignificant(self): 98 | return any(f.IsSignificant() for f in self.GetFlattenedFailures()) 99 | 100 | def __str__(self): 101 | flattened_failures = self.GetFlattenedFailures() 102 | if len(flattened_failures) == 1: 103 | return str(flattened_failures[0]) 104 | assert len(flattened_failures) > 0 105 | return ( 106 | 'Multiple failures:\n' + 107 | '\n\n'.join(str(f) for f in flattened_failures)) 108 | 109 | 110 | class Log(object): 111 | """A generic log type.""" 112 | TYPE_WIDTH = 10 # UNEXPECTED 113 | 114 | def __init__(self, message=''): 115 | self.message = message 116 | 117 | def __str__(self): 118 | # Makes every header be padded as much as the longest message. 119 | header = ('%%%ds' % self.TYPE_WIDTH) % self.TYPE.upper() 120 | leader = ('\n%%%ds ' % self.TYPE_WIDTH) % '' 121 | return ' '.join((header, leader.join(self.message.split('\n')))) 122 | 123 | 124 | class Received(Log): 125 | """For received commands.""" 126 | TYPE = LOG.RECEIVED 127 | 128 | 129 | class Matched(Log): 130 | """For matched commands.""" 131 | TYPE = LOG.MATCHED 132 | 133 | def __init__(self, line, mode): 134 | message = 'with "%s" (%s mode)' % (line, mode) 135 | super(Matched, self).__init__(message) 136 | 137 | 138 | class Responded(Log): 139 | """For system responses.""" 140 | TYPE = LOG.RESPONDED 141 | 142 | 143 | class Unexpected(Log): 144 | """For unexpected entities.""" 145 | TYPE = LOG.UNEXPECTED 146 | 147 | 148 | class ErrorLog(Log): 149 | """For error logs.""" 150 | TYPE = LOG.ERROR 151 | 152 | def __init__(self, extype, exval, tb): 153 | message = ''.join(traceback.format_exception(extype, exval, tb)) 154 | super(ErrorLog, self).__init__(message) 155 | -------------------------------------------------------------------------------- /examples/basics.vroom: -------------------------------------------------------------------------------- 1 | This is a vroom script. 2 | 3 | Everything that doesn't start with two spaces is a comment. Feel free to 4 | explain your tests. 5 | 6 | Vroom input lines start with ` > `. That's two spaces, a greater-than sign, 7 | and another space. They're passed like :map commands: 8 | 9 | > iHello, world! 10 | 11 | Easy as that. Output lines start with two spaces and check the current buffer. 12 | 13 | Hello, world! 14 | 15 | Whenever you break up your output tests, vroom goes back to checking the top of 16 | the buffer. Let's check the output again to make sure it hasn't changed: 17 | 18 | Hello, world! 19 | 20 | It's still good. 21 | 22 | You start a new test with three blank lines: 23 | 24 | 25 | 26 | This is a new test! Starting a new test is roughly equivalent to running 27 | 28 | > :stopinsert 29 | > :silent! bufdo! bdelete! 30 | 31 | and then increasing vroom's test counter. If you think three blank lines are 32 | unsightly, you may also begin a new test with the @clear directive. See 33 | examples/directives.vroom for details. 34 | 35 | 36 | 37 | You can leave vim in insert mode between commands, though it's not recommended: 38 | 39 | > iHello, 40 | Hello, 41 | > world! 42 | Hello, world! 43 | 44 | 45 | 46 | You may also change which buffer you're checking. 47 | 48 | > iThis text is in buffer THREE 49 | > :vnew 50 | > iThis text is in buffer 4. 51 | This text is in buffer THREE (3) 52 | This text is in buffer 4. (4) 53 | 54 | Notice the (3) and (4) at the end of the lines -- these are choosing the buffer 55 | to check, and they're called "vroom controls". 56 | 57 | (Why 3 and 4 instead of 1 and 2? Vim buffer numbers are sequential, but they 58 | don't ever reset during a vim session -- you just have to count how many buffers 59 | you've used. It's the nature of the beast.) 60 | 61 | Most vroom lines can end in vroom controls, which are a space followed by 62 | parenthesis at the end of a line. You may also do things like select a range of 63 | lines to check or change the match mode. See examples/controls.vroom for more. 64 | 65 | 66 | 67 | You may check message output with ` ~ ` lines. This is useful for catching and 68 | handling errors. Make sure you use `:echomsg` and not vanilla `:echo`! Echos are 69 | not persistent, and vroom has no way of checking them. 70 | 71 | > :echomsg "Hello, world!" 72 | ~ Hello, world! 73 | > :echomsg exists('x') 74 | ~ 0 75 | 76 | See examples/messages.vroom for more information. 77 | 78 | 79 | 80 | There are two useful shortcuts that you may appreciate. Firstly, you can use 81 | lines starting with ` :` to enter vim commands more succinctly: 82 | 83 | :echomsg "Another message!" 84 | ~ Another message! 85 | 86 | This is exactly equivalent to ` > :echomsg "Another message!"` 87 | 88 | Secondly, you can use lines starting with ` % ` to enter user input. 89 | 90 | % Goodbye, world. 91 | Goodbye, world. 92 | 93 | Which is exactly equivalent to ` > iGoodby, world.`: it enters insert 94 | mode, spits out your text, and exits insert mode. 95 | 96 | Be careful with these shortcuts! They only work from normal mode. If vim isn't in 97 | normal mode when you use them, things could get a little hairy.. 98 | 99 | :new 100 | > i 101 | :LaunchTheNukes 102 | > 103 | & :LaunchTheNukes 104 | & 105 | 106 | Notice how we just tried to match the output line `:LaunchTheNukes`. We couldn't 107 | write it verbatim, because vroom would have thought it was a control line! So we 108 | used ` & ` instead, which explicitly forces output checking. See 109 | examples/escaping.vroom for details. 110 | 111 | 112 | 113 | The currently running file can be found in the $VROOMFILE environment variable. 114 | It is local to the directory where vroom is started. 115 | 116 | :echomsg $VROOMFILE 117 | ~ *basics.vroom (glob) 118 | 119 | The relative path to the directory containing $VROOMFILE can be found in the 120 | $VROOMDIR environment variable. 121 | 122 | If vroom was started from the directory containing $VROOMFILE, $VROOMDIR will 123 | be set to '.', and so a file in the same directory as $VROOMFILE can be named 124 | using a path such as $VROOMDIR/foo.vim. 125 | 126 | :echomsg $VROOMDIR 127 | ~ (\.|(.+/)?examples) (regex) 128 | 129 | 130 | 131 | That's all there is to it! 132 | 133 | In more complicated tests you may need to sandbox vim, preventing it from doing 134 | things like hitting the network for data. To learn how to hijack system calls, 135 | see examples/system.vroom. 136 | 137 | If you have weird test output that you need to match, you may want to check out 138 | examples/escaping.vroom and examples/continuations.vroom. 139 | 140 | Then you just run the vroom script (you can use -v for extra output) on your 141 | vroom file, and away you go! 142 | -------------------------------------------------------------------------------- /examples/macros.vroom: -------------------------------------------------------------------------------- 1 | Sometimes you may find yourself repeating similar chunks of vroom code across 2 | different tests. For example, let's write some tests for the 'cindent' feature: 3 | 4 | Enter a small, unindented function 5 | % void func() 6 | % { 7 | % if (true) { 8 | % printf("hello\n!"); 9 | % } 10 | % } 11 | Enable cindent and set tabstop and shiftwidth to 2 12 | :set cin ts=2 sw=2 et 13 | > gg=G 14 | Now function should have a 2-space indentation: 15 | void func() 16 | { 17 | if (true) { 18 | printf("hello\n!"); 19 | } 20 | } 21 | & 22 | @end 23 | @clear 24 | 25 | Now let's test cindent again, but with another value for ts/sw. To make sure 26 | the previous indentation won't affect this one, we start with a clear buffer: 27 | % void func() 28 | % { 29 | % if (true) { 30 | % printf("hello\n!"); 31 | % } 32 | % } 33 | :set cin ts=4 sw=4 et 34 | > gg=G 35 | void func() 36 | { 37 | if (true) { 38 | printf("hello\n!"); 39 | } 40 | } 41 | & 42 | @end 43 | @clear 44 | 45 | The above pattern of writing tests can generalized like this: 46 | 47 | - Start with a clear buffer 48 | - Input some text 49 | - Call a function or command with some parameters 50 | - Verify if the buffer is in a expected state 51 | - Repeat but with a different set of parameters 52 | 53 | Macros can be used to create reusable chunks of vroom code and help reduce 54 | boilerplate across tests. Let's rewrite the above test using macros: 55 | 56 | @macro (input_unindented_function) 57 | % void func() 58 | % { 59 | % if (true) { 60 | % printf("hello\n!"); 61 | % } 62 | % } 63 | @endmacro 64 | 65 | The above defined a macro named 'input_unindented_function' that takes care of 66 | entering an unindented function in the current buffer. 67 | 68 | & 69 | @end 70 | 71 | As you can see, the buffer wasn't modified. The macro can be "executed" with 72 | a @do directive: 73 | 74 | @do (input_unindented_function) 75 | void func() 76 | { 77 | if (true) { 78 | printf("hello\n!"); 79 | } 80 | } 81 | & 82 | @end 83 | 84 | Now indent and verify output 85 | :set cin ts=4 sw=4 et 86 | > gg=G 87 | void func() 88 | { 89 | if (true) { 90 | printf("hello\n!"); 91 | } 92 | } 93 | & 94 | @end 95 | @clear 96 | 97 | A cool thing about macros is that the lines between the @macro/@endmacro 98 | directives can contain python format string syntax(the same syntax used by 99 | str.format()). For example: 100 | 101 | @macro (greet) 102 | % hello {subject} 103 | @endmacro 104 | @do (greet, subject='world') 105 | @do (greet, subject='vroom') 106 | hello world 107 | hello vroom 108 | & 109 | @end 110 | @clear 111 | 112 | That means we can generalize even the cindent verification: 113 | 114 | @macro (indent_and_verify) 115 | :set cin ts={count} sw={count} et 116 | > gg=G 117 | void func() 118 | {{ 119 | {fill:{count}}if (true) {{ 120 | {fill:{count}}{fill:{count}}printf("hello\n!"); 121 | {fill:{count}}}} 122 | }} 123 | Notice how any braces that are part of the output need to be escaped. This is 124 | only necessary when the macro is executed with arguments. 125 | & 126 | @endmacro 127 | 128 | Let's split into two macros to improve readability: 129 | 130 | @macro (verify) 131 | void func() 132 | {{ 133 | {indent}if (true) {{ 134 | {indent}{indent}printf("hello\n!"); 135 | {indent}}} 136 | }} 137 | & 138 | @endmacro 139 | 140 | @macro (indent_and_verify) 141 | Notice how macros can be redefined at any time 142 | :set cin ts={count} sw={count} et 143 | > gg=G 144 | @do (verify, indent='{fill:{count}}') 145 | @endmacro 146 | 147 | After the macro is defined we can easily test cindent for multiple ts/sw 148 | values. The keyword arguments passed to @do can contain simple python 149 | expressions: 150 | 151 | @do (input_unindented_function) 152 | @do (indent_and_verify, fill=' ', count=2) 153 | @clear 154 | @do (input_unindented_function) 155 | @do (indent_and_verify, fill=' ', count=4) 156 | @clear 157 | 158 | Since macros can contain `@do` directives, the test can be simplified even 159 | further: 160 | 161 | @macro (test_cindent) 162 | @do (input_unindented_function) 163 | @do (indent_and_verify, fill=' ', count={width}) 164 | @clear 165 | @endmacro 166 | 167 | @do (test_cindent, width=2) 168 | @do (test_cindent, width=4) 169 | @do (test_cindent, width=6) 170 | @do (test_cindent, width=8) 171 | 172 | It's important to understand parsing macro contents is delayed until a 173 | corresponding @do directive is found: 174 | 175 | @macro (insert_or_verify) 176 | {prefix}some text 177 | @endmacro 178 | 179 | Input the text 180 | 181 | @do (insert_or_verify, prefix='% ') 182 | @do (insert_or_verify, prefix='') 183 | @clear 184 | 185 | Finally, macro names can contain spaces for more descriptive names: 186 | 187 | @macro (test cindent for arbitrary widths) 188 | @do (test_cindent, width={width}) 189 | @endmacro 190 | 191 | @do (test cindent for arbitrary widths, width=2) 192 | @do (test cindent for arbitrary widths, width=4) 193 | @do (test cindent for arbitrary widths, width=8) 194 | 195 | For more details about python format syntax, see: 196 | https://docs.python.org/2/library/string.html#format-string-syntax 197 | -------------------------------------------------------------------------------- /vroom/buffer.py: -------------------------------------------------------------------------------- 1 | """Vroom buffer handling.""" 2 | import vroom.controls 3 | import vroom.test 4 | 5 | # Pylint is not smart enough to notice that all the exceptions here inherit from 6 | # vroom.test.Failure, which is a standard Exception. 7 | # pylint: disable-msg=nonstandard-exception 8 | 9 | 10 | class Manager(object): 11 | """Manages the vim buffers.""" 12 | 13 | def __init__(self, vim): 14 | self.vim = vim 15 | self.Unload() 16 | 17 | def Unload(self): 18 | """Unload the current buffer.""" 19 | self._loaded = False 20 | self._buffer = None 21 | self._data = [] 22 | self._line = None 23 | self._last_range = None 24 | 25 | def Load(self, buff): 26 | """Loads the requested buffer. 27 | 28 | If no buffer is loaded nor requested, the active buffer is used. 29 | Otherwise if no buffer is requested, the current buffer is used. 30 | Otherwise the requested buffer is loaded. 31 | 32 | Args: 33 | buff: The buffer to load. 34 | """ 35 | if self._loaded and buff is None: 36 | return 37 | self.Unload() 38 | self._data = self.vim.GetBufferLines(buff) 39 | self._buffer = buff 40 | self._loaded = True 41 | 42 | def View(self, start, end): 43 | """A generator over given lines in a buffer. 44 | 45 | When vim messages are viewed in this fashion, the messenger object will be 46 | notified that those messages were not unexpected. 47 | 48 | Args: 49 | start: The beginning of the range. 50 | end: A function to get the end of the range. 51 | Yields: 52 | An iterable over the range. 53 | Raises: 54 | NotEnoughOutput: when the range exceeds the buffer. 55 | """ 56 | # If no start line is given, we advance to the next line. Therefore, if the 57 | # buffer has not yet been inspected we want to start at one before line 0. 58 | self._line = -1 if self._line is None else self._line 59 | # Vim 1-indexes lines. 60 | if start == vroom.controls.SPECIAL_RANGE.CURRENT_LINE: 61 | start = self.vim.GetCurrentLine() - 1 62 | else: 63 | start = (self._line + 1) if start is None else (start - 1) 64 | 65 | # No need to decrement; vroom ranges are inclusive and vim 1-indexes. 66 | end = (start + 1) if end is None else end(start + 1) 67 | # End = 0 means check till end of buffer. 68 | end = len(self._data) if end == 0 else end 69 | # If there's an error, they'll want to know what we were looking at. 70 | self._last_range = (start, end) 71 | 72 | # Yield each relevant line in the range. 73 | for i in range(start, end): 74 | self._line = i 75 | if i < len(self._data): 76 | yield self._data[i] 77 | else: 78 | raise NotEnoughOutput(self.GetContext()) 79 | 80 | # 'range' is the most descriptive name here. Putting 'range' in kwargs and 81 | # pulling it out obfuscates the code. Sorry, pylint. (Same goes for 'buffer'.) 82 | def Verify( # pylint: disable-msg=redefined-builtin 83 | self, desired, buffer=None, range=None, mode=None): 84 | """Checks the contents of a vim buffer. 85 | 86 | Checks that all lines in the given range in the loaded buffer match the 87 | given line under the given mode. 88 | 89 | Args: 90 | desired: The line that everything should look like. 91 | buffer: The buffer to load. 92 | range: The range of lines to check. 93 | mode: The mode to match in. 94 | Raises: 95 | WrongOutput: if the output is wrong. 96 | """ 97 | self.Load(buffer) 98 | start, end = range or (None, None) 99 | for actual in self.View(start, end): 100 | if not vroom.test.Matches(desired, mode, actual): 101 | raise WrongOutput(desired, mode, self.GetContext()) 102 | 103 | # See self.Verify for the reasoning behind the pylint trump. 104 | def EnsureAtEnd(self, buffer): # pylint: disable-msg=redefined-builtin 105 | """Ensures that the test has verified to the end of the loaded buffer. 106 | 107 | Args: 108 | buffer: The buffer to load. 109 | Raises: 110 | BadOutput: If the buffer is not in a state to have its end checked. 111 | WrongOutput: If the buffer is not at the end. 112 | """ 113 | self.Load(buffer) 114 | self._last_range = (len(self._data), len(self._data)) 115 | if self._line is None: 116 | if self._data == [''] or not self._data: 117 | return 118 | msg = 'Misuse of @end: buffer has not been checked yet.' 119 | raise BadOutput(self.GetContext(), msg) 120 | if self._line != len(self._data) - 1: 121 | raise TooMuchOutput(self.GetContext()) 122 | 123 | def GetContext(self): 124 | """Information about what part of the buffer was being looked at. 125 | 126 | Invaluable in exceptions. 127 | 128 | Returns: 129 | Dict containing 'buffer', 'data', 'line', 'start', and 'end'. 130 | """ 131 | if (not self._loaded) or (self._last_range is None): 132 | return None 133 | (start, end) = self._last_range 134 | return { 135 | 'buffer': self._buffer, 136 | 'data': self._data, 137 | 'line': self._line, 138 | 'start': start, 139 | 'end': end, 140 | } 141 | 142 | 143 | class BadOutput(vroom.test.Failure): 144 | """Raised when vim's output is not the expected output.""" 145 | DESCRIPTION = 'Output does not match expectation.' 146 | 147 | def __init__(self, context, message=None): 148 | self.context = context 149 | super(BadOutput, self).__init__(message or self.DESCRIPTION) 150 | 151 | 152 | class WrongOutput(BadOutput): 153 | """Raised when a line fails to match the spec.""" 154 | 155 | def __init__(self, line, mode, context): 156 | """Makes the exception. 157 | 158 | Args: 159 | line: The expected line. 160 | mode: The match mode. 161 | context: The buffer context. 162 | """ 163 | self.context = context 164 | mode = mode or vroom.controls.DEFAULT_MODE 165 | msg = 'Expected "%s" in %s mode.' % (line, mode) 166 | super(WrongOutput, self).__init__(context, msg) 167 | 168 | 169 | class TooMuchOutput(BadOutput): 170 | """Raised when vim has more output than a vroom test wants.""" 171 | DESCRIPTION = 'Expected end of buffer.' 172 | 173 | 174 | class NotEnoughOutput(BadOutput): 175 | """Raised when vim has less output than a vroom test wants.""" 176 | DESCRIPTION = 'Unexpected end of buffer.' 177 | -------------------------------------------------------------------------------- /vroom/messages.py: -------------------------------------------------------------------------------- 1 | """A module to keep track of vim messages.""" 2 | 3 | import re 4 | 5 | import vroom 6 | import vroom.controls 7 | import vroom.test 8 | 9 | from vroom.result import Result 10 | 11 | # Pylint is not smart enough to notice that all the exceptions here inherit from 12 | # vroom.test.Failure, which is a standard Exception. 13 | # pylint: disable-msg=nonstandard-exception 14 | 15 | 16 | ERROR_GUESS = re.compile( 17 | r'^(E\d+\b|ERR(OR)?\b|Error detected while processing .*)') 18 | STRICTNESS = vroom.Specification( 19 | STRICT='STRICT', 20 | RELAXED='RELAXED', 21 | ERRORS='GUESS-ERRORS') 22 | DEFAULT_MODE = vroom.controls.MODE.VERBATIM 23 | 24 | 25 | def GuessNewMessages(old, new): 26 | """Guess which messages in a message list are new. 27 | 28 | >>> GuessNewMessages([1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7]) 29 | [5, 6, 7] 30 | >>> GuessNewMessages([1, 2, 3, 4], [4, 5, 6, 7]) 31 | [5, 6, 7] 32 | >>> GuessNewMessages([1, 2, 3, 4], [5, 6, 7]) 33 | [5, 6, 7] 34 | >>> GuessNewMessages([1, 2, 3, 4], [4, 1, 2, 3]) 35 | [1, 2, 3] 36 | 37 | Args: 38 | old: The old message list. 39 | new: The new message list. 40 | Returns: 41 | The new messages. Probably. 42 | """ 43 | # This is made complicated by the fact that vim can drop messages, sometimes 44 | # after as few as 20 messages. When that's the case we have to guess a bit. 45 | # Technically, it's always possible to miss exactly [MESSAGE_MAX] messages 46 | # if you echo them out in a perfect cycle in one command. So it goes. 47 | # Message lists are straight from vim, so oldest is first. 48 | for i in range(len(old)): 49 | if old[i:] == new[:len(old) - i]: 50 | return new[len(old) - i:] 51 | return new[:] 52 | 53 | 54 | def StartsWithBuiltinMessages(messages): 55 | """Whether the message list starts with the vim built in messages.""" 56 | return len(messages) >= 2 and not messages[0] and messages[1] == ( 57 | 'Messages maintainer: Bram Moolenaar ') 58 | 59 | 60 | def StripBuiltinMessages(messages): 61 | """Strips the builtin messages.""" 62 | assert len(messages) >= 2 63 | return messages[2:] 64 | 65 | 66 | class Messenger(object): 67 | """Keeps an eye on vim, watching out for unexpected/error-like messages.""" 68 | 69 | def __init__(self, vim, env, writer): 70 | """Creates the messenger. 71 | 72 | Args: 73 | vim: The vim handler. 74 | env: The vroom Environment object. 75 | writer: A place to log messages. 76 | """ 77 | self.vim = vim 78 | self.env = env 79 | self.writer = writer.messages 80 | 81 | def Verify(self, old, new, expectations): 82 | """Verifies that the message state is OK. 83 | 84 | Args: 85 | old: What the messages were before the command. 86 | new: What the messages were after the command. 87 | expectations: What the command was supposed to message about. 88 | Returns: 89 | Result.Error(vroom.test.Failures[MessageFailure]): 90 | If any message-related failures were detected. 91 | Result.Success(): Otherwise 92 | """ 93 | if StartsWithBuiltinMessages(old) and StartsWithBuiltinMessages(new): 94 | old = StripBuiltinMessages(old) 95 | new = StripBuiltinMessages(new) 96 | unread = GuessNewMessages(old, new) 97 | failures = [] 98 | for message in unread: 99 | self.writer.Log(vroom.test.Received(message)) 100 | for (desired, mode) in expectations: 101 | mode = mode or DEFAULT_MODE 102 | while True: 103 | try: 104 | message = unread.pop(0) 105 | except IndexError: 106 | expectation = '"%s" (%s mode)' % (desired, mode) 107 | failures.append( 108 | MessageNotReceived(expectation, new, self.vim.writer.Logs())) 109 | break 110 | if vroom.test.Matches(desired, mode, message): 111 | self.writer.Log(vroom.test.Matched(desired, mode)) 112 | break 113 | # Consume unexpected blank if it's the last message. Vim adds spurious 114 | # blank lines after leaving insert mode. 115 | # This is done after checking for expected blank messages. 116 | if message == '' and not unread: 117 | break 118 | try: 119 | self.Unexpected(message, new) 120 | except MessageFailure as e: 121 | failures.append(e) 122 | # Consume unexpected blank if it's the last message. 123 | if unread and unread[-1] == '': 124 | unread.pop(-1) 125 | for remaining in unread: 126 | try: 127 | self.Unexpected(remaining, new) 128 | except MessageFailure as e: 129 | failures.append(e) 130 | 131 | if failures: 132 | return Result.Error(vroom.test.Failures(failures)) 133 | else: 134 | return Result.Success() 135 | 136 | def Unexpected(self, message, new): 137 | """Handles an unexpected message.""" 138 | self.writer.Log(vroom.test.Unexpected()) 139 | if self.env.message_strictness == STRICTNESS.STRICT: 140 | raise UnexpectedMessage(message, new, self.vim.writer.Logs()) 141 | elif self.env.message_strictness == STRICTNESS.ERRORS: 142 | if ERROR_GUESS.match(message): 143 | raise SuspectedError(message, new, self.vim.writer.Logs()) 144 | raise UnexpectedMessage( 145 | message, new, self.vim.writer.Logs(), is_significant=False) 146 | 147 | 148 | class MessageFailure(vroom.test.Failure): 149 | """For generic messaging troubles.""" 150 | DESCRIPTION = 'Messaging failure.' 151 | CONTEXT = 12 152 | 153 | def __init__(self, message, messages, commands=None): 154 | self.messages = messages[-self.CONTEXT:] 155 | if commands: 156 | self.commands = commands[-self.CONTEXT:] 157 | msg = self.DESCRIPTION % {'message': message} 158 | super(MessageFailure, self).__init__(msg) 159 | 160 | 161 | class MessageNotReceived(MessageFailure): 162 | """For when an expected message is never messaged.""" 163 | DESCRIPTION = 'Expected message not received:\n%(message)s' 164 | 165 | 166 | class UnexpectedMessage(MessageFailure): 167 | """For when an unexpected message is found.""" 168 | DESCRIPTION = 'Unexpected message:\n%(message)s' 169 | 170 | def __init__(self, message, messages, commands=None, is_significant=True): 171 | super(UnexpectedMessage, self).__init__(message, messages, commands) 172 | self._is_significant = is_significant 173 | 174 | def IsSignificant(self): 175 | return self._is_significant 176 | 177 | 178 | class SuspectedError(MessageFailure): 179 | """For when a message that looks like an error is found.""" 180 | DESCRIPTION = 'Suspected error message:\n%(message)s' 181 | -------------------------------------------------------------------------------- /vroom/runner.py: -------------------------------------------------------------------------------- 1 | """The Vroom test runner. Does the heavy lifting.""" 2 | import sys 3 | 4 | import vroom 5 | import vroom.actions 6 | import vroom.args 7 | import vroom.buffer 8 | import vroom.command 9 | import vroom.environment 10 | import vroom.output 11 | import vroom.shell 12 | import vroom.test 13 | import vroom.vim 14 | 15 | from vroom.result import Result 16 | 17 | # Pylint is not smart enough to notice that all the exceptions here inherit from 18 | # vroom.test.Failure, which is a standard Exception. 19 | # pylint: disable-msg=nonstandard-exception 20 | 21 | 22 | class Vroom(object): 23 | """Executes vroom tests.""" 24 | 25 | def __init__(self, filename, args): 26 | """Creates the vroom test. 27 | 28 | Args: 29 | filename: The name of the file to execute. 30 | args: The vroom command line flags. 31 | """ 32 | self._message_strictness = args.message_strictness 33 | self._system_strictness = args.system_strictness 34 | self._lineno = None 35 | # Whether this vroom instance has left the terminal in an unknown state. 36 | self.dirty = False 37 | self.env = vroom.environment.Environment(filename, args) 38 | self.ResetCommands() 39 | 40 | def ResetCommands(self): 41 | self._running_command = None 42 | self._command_queue = [] 43 | 44 | def GetCommand(self): 45 | if not self._command_queue: 46 | self.PushCommand(None, None) 47 | return self._command_queue[-1] 48 | 49 | def PushCommand(self, line, delay=None): 50 | self._command_queue.append( 51 | vroom.command.Command(line, self._lineno, delay or 0, self.env)) 52 | 53 | def ExecuteCommands(self): 54 | if not self._command_queue: 55 | return 56 | self.env.buffer.Unload() 57 | for self._running_command in self._command_queue: 58 | result = self._running_command.Execute() 59 | if result.IsError() and result.value.IsSignificant(): 60 | raise result.value 61 | self.ResetCommands() 62 | 63 | def __call__(self, filehandle): 64 | """Runs vroom on a file. 65 | 66 | Args: 67 | filehandle: The open file to run on. 68 | Returns: 69 | A writer to write the test output later. 70 | """ 71 | lines = list(filehandle) 72 | try: 73 | self.env.writer.Begin(lines) 74 | self.env.vim.Start() 75 | self.Run(lines) 76 | except vroom.ParseError as e: 77 | self.Record(vroom.test.RESULT.ERROR, e) 78 | except vroom.test.Failure as e: 79 | self.Record(vroom.test.RESULT.FAILED, e) 80 | except vroom.vim.Quit as e: 81 | # TODO(dbarnett): Revisit this when terminal reset is no longer necessary. 82 | if e.is_fatal: 83 | raise 84 | self.Record(vroom.test.RESULT.ERROR, e) 85 | except Exception: 86 | self.env.writer.actions.Exception(*sys.exc_info()) 87 | finally: 88 | if not self.env.args.interactive: 89 | if not self.env.vim.Quit(): 90 | self.dirty = True 91 | self.env.vim.Kill() 92 | status = self.env.writer.Status() 93 | if status != vroom.output.STATUS.PASS and self.env.args.interactive: 94 | self.env.vim.Output(self.env.writer) 95 | self.env.vim.process.wait() 96 | return self.env.writer 97 | 98 | def Record(self, result, error=None): 99 | """Add context to an error and log it. 100 | 101 | The current line number is added to the context when possible. 102 | 103 | Args: 104 | result: The log type, should be a member of vroom.test.RESULT 105 | error: The exception, if any. 106 | """ 107 | # Figure out the line where the event happened. 108 | if self._running_command and self._running_command.lineno is not None: 109 | lineno = self._running_command.lineno 110 | elif self._lineno is not None: 111 | lineno = self._lineno 112 | else: 113 | lineno = getattr(error, 'lineno', None) 114 | if lineno is not None: 115 | self.env.writer.actions.Log(result, lineno, error) 116 | else: 117 | self.env.writer.actions.Error(result, error) 118 | 119 | def Test(self, function, *args, **kwargs): 120 | self.ExecuteCommands() 121 | function(*args, **kwargs) 122 | 123 | def Run(self, lines): 124 | """Runs a vroom file. 125 | 126 | Args: 127 | lines: List of lines in the file. 128 | """ 129 | actions = list(vroom.actions.Parse(lines)) 130 | for (self._lineno, action, line, controls) in actions: 131 | if action == vroom.actions.ACTION.PASS: 132 | # Line breaks send you back to the top of the buffer. 133 | self.env.buffer.Unload() 134 | # Line breaks distinguish between consecutive system hijacks. 135 | self.GetCommand().LineBreak() 136 | elif action == vroom.actions.ACTION.TEXT: 137 | self.PushCommand('i%s' % line, **controls) 138 | elif action == vroom.actions.ACTION.COMMAND: 139 | self.PushCommand(':%s' % line, **controls) 140 | elif action == vroom.actions.ACTION.INPUT: 141 | self.PushCommand(line, **controls) 142 | elif action == vroom.actions.ACTION.MESSAGE: 143 | self.GetCommand().ExpectMessage(line, **controls) 144 | elif action == vroom.actions.ACTION.SYSTEM: 145 | self.GetCommand().ExpectSyscall(line, **controls) 146 | elif action == vroom.actions.ACTION.HIJACK: 147 | self.GetCommand().RespondToSyscall(line, **controls) 148 | elif action == vroom.actions.ACTION.DIRECTIVE: 149 | if line == vroom.actions.DIRECTIVE.CLEAR: 150 | self.ExecuteCommands() 151 | self.env.writer.actions.Log(vroom.test.RESULT.PASSED, self._lineno) 152 | self.env.vim.Clear() 153 | elif line == vroom.actions.DIRECTIVE.END: 154 | self.Test(self.env.buffer.EnsureAtEnd, **controls) 155 | elif line == vroom.actions.DIRECTIVE.MESSAGES: 156 | self.ExecuteCommands() 157 | strictness = controls.get('messages') or self._message_strictness 158 | self.env.message_strictness = strictness 159 | elif line == vroom.actions.DIRECTIVE.SYSTEM: 160 | self.ExecuteCommands() 161 | strictness = controls.get('system') or self._system_strictness 162 | self.env.system_strictness = strictness 163 | else: 164 | raise vroom.ConfigurationError('Unrecognized directive "%s"' % line) 165 | elif action == vroom.actions.ACTION.OUTPUT: 166 | self.Test(self.env.buffer.Verify, line, **controls) 167 | else: 168 | raise vroom.ConfigurationError('Unrecognized action "%s"' % action) 169 | self.ExecuteCommands() 170 | self.env.writer.actions.Log(vroom.test.RESULT.PASSED, self._lineno or 0) 171 | self.env.vim.Quit() 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vroom: Launch vim tests 2 | 3 | ![Vroom](/images/vroom_logo.png?raw=true) 4 | 5 | ![usage screencast](/images/usage_screencast.gif) 6 | 7 | _**Vroom is experimental.** There are still some issues with vim that we 8 | haven't figured out how to work around. We reserve the right to make backwards 9 | incompatible changes in order to address these._ 10 | 11 | Vroom is for testing vim. 12 | 13 | Let's say you're a vimscript author. You want to test your new plugin. You could 14 | find a nice vimscript test suite, but that only lets you test your vimscript 15 | functions. What you really want is a way to specify vim commands — actual input 16 | keys that that the user hits — and then verify vim's output. 17 | 18 | Enter vroom. 19 | 20 | This is a vroom test. 21 | 22 | > iHello, world! 23 | Hello, world! 24 | 25 | The above vroom test opens vim, sends the keys `iHello, world!` and then 26 | verifies that the contents of the current buffer is `Hello, world!`. 27 | 28 | Things can get much more complex than this, of course. You need to be able to 29 | check the output of multiple buffers. You need to check what messages your 30 | functions echo. You need to sandbox vim, capture its system commands, and 31 | respond with dummy data. And a few shortcuts would be nice. 32 | 33 | Never fear! Vroom has it all. Check the examples for details and more 34 | documentation. examples/basics.vroom is a good place to start. 35 | 36 | Run `vroom -h` to learn about vroom's configuration options. 37 | 38 | Did you accidentally set off a giant vroom test that's running too fast to halt? 39 | Never fear! Pop open another terminal and `vroom --murder`. 40 | Make sure the `--servername` flag matches with the vroom you're trying to kill. 41 | You may need to run `reset` in the terminal with the murdered vroom. 42 | 43 | See the 44 | [Tips and Tricks page](https://github.com/google/vroom/wiki/Tips-and-Tricks) 45 | page for some strategies for getting the most out of vroom. 46 | 47 | ## Usage 48 | 49 | Vroom is invoked from the command-line on `.vroom` files. Here are some 50 | examples of usage: 51 | 52 | * Running a single file, native vim runner (must have `+clientserver` enabled): 53 | 54 | ```sh 55 | vroom myplugin/vroom/somefile.vroom --servername=FOO 56 | ``` 57 | 58 | * With native vim, finding files below current directory: 59 | 60 | ```sh 61 | vroom --crawl --servername=FOO` 62 | ``` 63 | 64 | * With neovim (must have installed both neovim and neovim python plugin): 65 | 66 | ```sh 67 | vroom --crawl --neovim --servername=FOO 68 | ``` 69 | 70 | * Without running setup.py and with neovim, assuming curdir=vroom repo root: 71 | 72 | ```sh 73 | PYTHONPATH=$PWD python3 vroom/__main__.py --neovim --crawl --servername=FOO` 74 | ``` 75 | 76 | See `vroom --help` and https://github.com/google/vroom/wiki for more info on 77 | usage. 78 | 79 | ## Installation 80 | 81 | Note that Vroom requires a version of vim built with the `+clientserver` 82 | option (run `vim --version` to check). See `:help clientserver` for 83 | additional requirements. 84 | 85 | If you're on Ubuntu or Debian, you can install 86 | [release packages](https://github.com/google/vroom/releases) from GitHub. 87 | 88 | Otherwise, the easiest way to install vroom is to clone the vroom repository 89 | from GitHub, cd into the vroom directory, and run 90 | ```sh 91 | python3 setup.py build && sudo python3 setup.py install 92 | ``` 93 | 94 | Vim 7.4.384 and later have built-in syntax support for the vroom filetype. You 95 | can install the standalone 96 | [ft-vroom plugin](https://github.com/google/vim-ft-vroom) for older versions of 97 | vim. 98 | 99 | ## Vroom cheat sheet 100 | 101 | Below is a table of the special symbols and conventions vroom recognizes. See 102 | the files under [examples/](examples/) and in particular 103 | [examples/basics.vroom](examples/basics.vroom) for explanations. 104 | 105 | 107 | | Symbol | Description | Action | Example | Controls | 108 | | ------- | --------------- | --------------- | ------------------------- | ------------------------------------ | 109 | | | unindented line | comment | `This is a comment` | | 110 | | `  > ` | gt leader | input | `  > iHello, world!` | `(N.Ns)` (delay) | 111 | | `  :` | colon leader | command | `  :echomsg 'A message'` | `(N.Ns)` (delay) | 112 | | `  % ` | percent leader | text | `  % Sent to buffer` | `(N.Ns)` (delay) | 113 | | `  ` | 2-space indent | output (buffer) | `  Compared to buffer` | `(N)` (buf number) | 114 | | `  & ` | ampersand | output | `  & :LiteralText` | `(N)` (buf number) | 115 | | `  ~ ` | tilde leader | message | `  ~ Echo'd!` | match modes
(default: verbatim) | 116 | | `  \|` | pipe leader | continuation | `  \|…TO A BIGGER HOUSE!` | | 117 | | `  ! ` | bang leader | system | `  ! echo From Vim` | match modes
(default: regex) | 118 | | `  $ ` | dollar leader | hijack | `  $ Nope, from vroom` | output channels
(default: stdout) | 119 | | `  @` | at leader | directive | `  @clear` | varies | 120 | 121 | Special controls: 122 | 123 | * match modes (for message and system): `(verbatim)`, `(glob)`, `(regex)` 124 | * output channels (for hijack): `(stdout)`, `(stderr)`, `(status)`, `(command)` 125 | 126 | Vroom also supports several built-in directives. See 127 | [examples/directives.vroom](examples/directives.vroom) and 128 | [examples/macros.vroom](examples/macros.vroom) for explanations. 129 | 130 | Directives: 131 | 132 | * `@clear` — Clear buffer contents (also triggered by 3 blank vroom lines). 133 | * `@end` — Ensure buffer matching reached end of buffer lines. 134 | * `@messages` — Override strictness for unexpected messages. 135 | * `@system` — Override strictness for unexpected system calls. 136 | * `@macro` — Define vroom macro. 137 | * `@endmacro` — End vroom macro and resume normal vroom processing. 138 | * `@do` — Invoke vroom macro defined with `@macro`. 139 | 140 | ## Neovim mode 141 | 142 | By default, vroom uses vim to execute vroom files. You can instead invoke it 143 | with the `--neovim` flag to execute vroom files inside neovim. 144 | 145 | To use it, you need to install the neovim-mode dependencies: 146 | 147 | * Install neovim for your platform according to the directions at 148 | https://github.com/neovim/neovim/wiki/Installing. 149 | * Install [neovim/python-client](https://github.com/neovim/python-client): 150 | ```sh 151 | sudo pip3 install neovim 152 | ``` 153 | 154 | ## Travis CI 155 | 156 | You can configure your vim plugin's vroom files to be tested continuously in 157 | [Travis CI](https://travis-ci.org). 158 | 159 | Just create a .travis.yml file at the root of your repository. The particulars 160 | may vary for your plugin, but here's an example configuration: 161 | 162 | ```YAML 163 | language: generic 164 | before_script: 165 | # Install your desired version of vroom. 166 | - wget https://github.com/google/vroom/releases/download/v0.12.0/vroom_0.12.0-1_all.deb 167 | - sudo dpkg -i vroom_0.12.0-1_all.deb 168 | # Install vim. 169 | - sudo apt-get install vim-gnome 170 | # Vroom's vim mode currently requires a running X server. 171 | - export DISPLAY=:99.0 172 | - sh -e /etc/init.d/xvfb start 173 | # If your plugin depends on maktaba, clone maktaba into a sibling directory. 174 | - git clone https://github.com/google/vim-maktaba.git ../maktaba/ 175 | script: 176 | - vroom --crawl ./vroom/ 177 | ``` 178 | 179 | It's also possible to test your plugin against neovim, but the recommended 180 | instructions are still being finalized. Details coming soon. 181 | 182 | ## Known issues 183 | 184 | Vroom uses vim as a server. Unfortunately, we don't yet have a reliable way to 185 | detect when vim has finished processing commands. Vroom currently relies upon 186 | arbitrary delays. As such, tests run more slowly than is necessary. Furthermore, 187 | some lengthy commands in vroom tests require additional arbitrary delays in 188 | order to make the tests pass. 189 | 190 | We're still looking for workarounds. (If you, like us, wish vim had a sane 191 | client/server architecture, consider 192 | [supporting](https://www.bountysource.com/fundraisers/539-neovim-first-iteration) 193 | [neovim](https://github.com/neovim/neovim).) 194 | -------------------------------------------------------------------------------- /vroom/args.py: -------------------------------------------------------------------------------- 1 | """Vroom command line arguments.""" 2 | import argparse 3 | from argparse import SUPPRESS 4 | import glob 5 | import itertools 6 | import os.path 7 | import sys 8 | 9 | import vroom 10 | import vroom.color 11 | import vroom.messages 12 | import vroom.shell 13 | 14 | parser = argparse.ArgumentParser( 15 | description='Vroom: launch your tests.', 16 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 17 | parser.add_argument( 18 | '--version', action='version', version='%(prog)s ' + vroom.__version__) 19 | 20 | 21 | class DirectoryArg(argparse.Action): 22 | """An argparse action for a valid directory path.""" 23 | 24 | def __call__(self, _, namespace, values, option_string=None): 25 | if not os.path.isdir(values): 26 | raise argparse.ArgumentTypeError('Invalid directory "%s"' % values) 27 | if not os.access(values, os.R_OK): 28 | raise argparse.ArgumentTypeError('Cannot read directory "%s"' % values) 29 | setattr(namespace, self.dest, values) 30 | 31 | 32 | # 33 | # Ways to run vroom. 34 | 35 | parser.add_argument( 36 | 'filename', 37 | nargs='*', 38 | default=SUPPRESS, 39 | help=""" 40 | The vroom file(s) to run. 41 | """) 42 | 43 | parser.add_argument( 44 | '--crawl', 45 | action=DirectoryArg, 46 | nargs='?', 47 | const='.', 48 | default=None, 49 | metavar='DIR', 50 | help=""" 51 | Crawl [DIR] looking for vroom files. 52 | if [DIR] is not given, the current directory is crawled. 53 | """) 54 | 55 | parser.add_argument( 56 | '--skip', 57 | nargs='+', 58 | default=[], 59 | metavar='PATH', 60 | help=""" 61 | Ignore PATH when using --crawl. 62 | PATH may refer to a test or a directory containing tests. 63 | PATH must be relative to the --crawl directory. 64 | """) 65 | 66 | management_group = parser.add_argument_group( 67 | 'management', 68 | 'Manage other running vroom processes') 69 | management_group.add_argument( 70 | '--murder', 71 | action='store_true', 72 | default=False, 73 | help=""" 74 | Kill a running vroom test. 75 | This will kill the first vroom (beside the current process) found. 76 | If you want to kill a specific vroom, find the process number and kill it 77 | yourself. 78 | """) 79 | 80 | 81 | # 82 | # Vim configuration 83 | 84 | parser.add_argument( 85 | '-s', 86 | '--servername', 87 | default='VROOM', 88 | help=""" 89 | The vim servername (see :help clientserver). 90 | Use this to help vroom differentiate between vims if you want to run multiple 91 | vrooms at once. 92 | """) 93 | 94 | parser.add_argument( 95 | '-u', 96 | '--vimrc', 97 | default='NONE', 98 | help=""" 99 | vimrc file to use. 100 | """) 101 | 102 | parser.add_argument( 103 | '-i', 104 | '--interactive', 105 | action='store_true', 106 | help=""" 107 | Keeps vim open after a vroom failure, allowing you to inspect vim's state. 108 | """) 109 | 110 | parser.add_argument( 111 | '--neovim', 112 | action='store_true', 113 | help=""" 114 | Run Neovim instead of Vim 115 | """) 116 | 117 | # 118 | # Timing 119 | 120 | parser.add_argument( 121 | '-d', 122 | '--delay', 123 | type=float, 124 | # See Parse for the real default 125 | default=SUPPRESS, 126 | metavar='DELAY', 127 | help=""" 128 | Delay after each vim command (in seconds). 129 | (default: 0.09 for Vim, 0.00 for Neovim) 130 | """) 131 | 132 | parser.add_argument( 133 | '--shell-delay', 134 | type=float, 135 | # See Parse for the real default 136 | default=SUPPRESS, 137 | metavar='SHELL_DELAY', 138 | help=""" 139 | Extra delay (in seconds) after a vim command that's expected to trigger a shell 140 | command. (default: 0.25 for Vim, 0.00 for Neovim) 141 | """) 142 | 143 | parser.add_argument( 144 | '-t', 145 | '--startuptime', 146 | type=float, 147 | default=0.5, 148 | metavar='STARTUPTIME', 149 | help=""" 150 | How long to wait for vim to start (in seconds). This option is ignored for 151 | Neovim. 152 | """) 153 | 154 | # 155 | # Output configuration 156 | 157 | parser.add_argument( 158 | '-o', 159 | '--out', 160 | default=sys.stdout, 161 | type=argparse.FileType('w'), 162 | metavar='FILE', 163 | help=""" 164 | Write test output to [FILE] instead of STDOUT. 165 | Vroom output should never be redirected, as vim will want to control stdout for 166 | the duration of the testing. 167 | """) 168 | 169 | parser.add_argument( 170 | '-v', 171 | '--verbose', 172 | action='store_true', 173 | help=""" 174 | Increase the amount of test output. 175 | """) 176 | 177 | parser.add_argument( 178 | '--nocolor', 179 | dest='color', 180 | action='store_const', 181 | const=vroom.color.Colorless, 182 | default=vroom.color.Colored, 183 | help=""" 184 | Turn off color in output. 185 | """) 186 | 187 | parser.add_argument( 188 | '--dump-messages', 189 | nargs='?', 190 | const=True, 191 | default=None, 192 | type=argparse.FileType('w'), 193 | metavar='FILE', 194 | help=""" 195 | Dump a log of vim messages received during execution. 196 | See :help messages in vim. 197 | Logs are written to [FILE], or to the same place as --out if [FILE] is omitted. 198 | """) 199 | 200 | parser.add_argument( 201 | '--dump-commands', 202 | nargs='?', 203 | const=True, 204 | default=None, 205 | type=argparse.FileType('w'), 206 | metavar='FILE', 207 | help=""" 208 | Dump a list of command sent to vim during execution. 209 | Logs are written to [FILE], or to the same place as --out if [FILE] is omitted. 210 | """) 211 | 212 | 213 | parser.add_argument( 214 | '--dump-syscalls', 215 | nargs='?', 216 | const=True, 217 | default=None, 218 | type=argparse.FileType('w'), 219 | metavar='FILE', 220 | help=""" 221 | Dump vim system call logs to [FILE]. 222 | Logs are written to [FILE], or to the same place as --out if [FILE] is omitted. 223 | """) 224 | 225 | 226 | # 227 | # Strictness configuration 228 | 229 | parser.add_argument( 230 | '--message-strictness', 231 | choices=vroom.messages.STRICTNESS.Values(), 232 | default=vroom.messages.STRICTNESS.ERRORS, 233 | help=""" 234 | How to deal with unexpected messages. 235 | When STRICT, unexpected messages will be treated as errors. 236 | When RELAXED, unexpected messages will be ignored. 237 | When GUESS-ERRORS (default), unexpected messages will be ignored unless vroom 238 | things they look suspicious. Suspicious messages include things formatted like 239 | vim errors like "E86: Buffer 3 does not exist" and messages that start with 240 | ERROR. 241 | """) 242 | 243 | parser.add_argument( 244 | '--system-strictness', 245 | choices=vroom.shell.STRICTNESS.Values(), 246 | default=vroom.shell.STRICTNESS.STRICT, 247 | help=""" 248 | How to deal with unexpected system calls. 249 | When STRICT (default), unexpected system calls will be treated as errors. 250 | When RELAXED, unexpected system calls will be ignored. 251 | """) 252 | 253 | 254 | # 255 | # Environment configuration 256 | 257 | parser.add_argument( 258 | '--shell', 259 | default='shell.vroomfaker', 260 | help=""" 261 | The dummy shell executable (either a path or something on the $PATH). 262 | Defaults to the right thing if you installed vroom normally. 263 | """) 264 | 265 | parser.add_argument( 266 | '--responder', 267 | default='respond.vroomfaker', 268 | help=""" 269 | The dummy responder executable (either a path or something on the $PATH). 270 | Defaults to the right thing if you installed vroom normally. 271 | """) 272 | 273 | 274 | def Parse(args): 275 | """Parse the given arguments. 276 | 277 | Does a bit of magic to make sure that color isn't printed when output is being 278 | piped to a file. 279 | 280 | Does a bit more magic so you can use that --dump-messages and its ilk follow 281 | --out by default, instead of always going to stdout. 282 | 283 | Expands the filename arguments and complains if they don't point to any real 284 | files (unless vroom's doing something else). 285 | 286 | Args: 287 | args: The arguments to parse 288 | Returns: 289 | argparse.Namespace of the parsed args. 290 | Raises: 291 | ValueError: If the args are bad. 292 | """ 293 | args = parser.parse_args(args) 294 | 295 | if args.out != sys.stdout: 296 | args.color = vroom.color.Colorless 297 | 298 | if not hasattr(args, 'delay'): 299 | # Default delay is 0.09 for Vim, 0 for Neovim 300 | args.delay = 0 if args.neovim else 0.09 301 | if not hasattr(args, 'shell_delay'): 302 | # Default shell delay is 0.25 for Vim, 0 for Neovim 303 | args.shell_delay = 0 if args.neovim else 0.25 304 | 305 | for dumper in ('dump_messages', 'dump_commands', 'dump_syscalls'): 306 | if getattr(args, dumper) is True: 307 | setattr(args, dumper, args.out) 308 | 309 | args.filenames = list(itertools.chain( 310 | Crawl(args.crawl, args.skip), 311 | *map(Expand, getattr(args, 'filename', [])))) 312 | if not args.filenames and not args.murder: 313 | raise ValueError('Nothing to do.') 314 | if args.murder and args.filenames: 315 | raise ValueError( 316 | 'You may birth tests and you may end them, but not both at once!') 317 | 318 | return args 319 | 320 | 321 | def Close(args): 322 | """Cleans up an argument namespace, closing files etc. 323 | 324 | Args: 325 | args: The argparse.Namespace to close. 326 | """ 327 | optfiles = [args.dump_messages, args.dump_commands, args.dump_syscalls] 328 | for optfile in filter(bool, optfiles): 329 | optfile.close() 330 | args.out.close() 331 | 332 | 333 | def Expand(filename): 334 | """Expands a filename argument into a list of relevant filenames. 335 | 336 | Args: 337 | filename: The filename to expand. 338 | Raises: 339 | ValueError: When filename is non-existent. 340 | Returns: 341 | All vroom files in the directory (if it's a directory) and all files 342 | matching the glob (if it's a glob). 343 | """ 344 | if os.path.isdir(filename): 345 | return glob.glob(os.path.join(filename, '*.vroom')) 346 | files = list(glob.glob(filename)) 347 | if not files and os.path.exists(filename + '.vroom'): 348 | files = [filename + '.vroom'] 349 | elif not files and not glob.has_magic(filename): 350 | raise ValueError('File "%s" not found.' % filename) 351 | return files 352 | 353 | 354 | def IgnoredPaths(directory, skipped): 355 | for path in skipped: 356 | # --skip paths must be relative to the --crawl directory. 357 | path = os.path.join(directory, path) 358 | # All ignored paths which do not end in '.vroom' are assumed to be 359 | # directories. We have to make sure they've got a trailing slash, or 360 | # --skip=foo will axe anything with a foo prefix (foo/, foobar/, etc.) 361 | if not path.endswith('.vroom'): 362 | path = os.path.join(path, '') 363 | yield path 364 | 365 | 366 | def Crawl(directory, ignored): 367 | """Crawls a directory looking for vroom files. 368 | 369 | Args: 370 | directory: The directory to crawl, may be None. 371 | ignored: A list of paths (absolute or relative to crawl directory) that will 372 | be pruned from the crawl results. 373 | Yields: 374 | the vroom files. 375 | """ 376 | if not directory: 377 | return 378 | 379 | ignored = list(IgnoredPaths(directory, ignored)) 380 | 381 | for (dirpath, dirnames, filenames) in os.walk(directory): 382 | # Traverse directories in alphabetical order. Default order fine for fnames. 383 | dirnames.sort() 384 | for filename in filenames: 385 | fullpath = os.path.join(dirpath, filename) 386 | for ignore in ignored: 387 | shared = os.path.commonprefix([ignore, fullpath]) 388 | if shared == ignore: 389 | break 390 | else: 391 | if filename.endswith('.vroom'): 392 | yield fullpath 393 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /vroom/shell.py: -------------------------------------------------------------------------------- 1 | """Vroom fake shell bridge.""" 2 | import json 3 | import os 4 | import os.path 5 | import pickle 6 | import re 7 | import shlex 8 | import tempfile 9 | 10 | import vroom 11 | import vroom.controls 12 | import vroom.test 13 | 14 | from vroom.result import Result 15 | 16 | # Pylint is not smart enough to notice that all the exceptions here inherit from 17 | # vroom.test.Failure, which is a standard Exception. 18 | # pylint: disable-msg=nonstandard-exception 19 | 20 | VROOMFILE_VAR = 'VROOMFILE' 21 | VROOMDIR_VAR = 'VROOMDIR' 22 | LOG_FILENAME_VAR = 'VROOM_SHELL_LOGFILE' 23 | CONTROL_FILENAME_VAR = 'VROOM_SHELL_CONTROLLFILE' 24 | ERROR_FILENAME_VAR = 'VROOM_SHELL_ERRORFILE' 25 | 26 | CONTROL = vroom.Specification( 27 | EXPECT='expect', 28 | RESPOND='respond') 29 | 30 | STRICTNESS = vroom.Specification( 31 | STRICT='STRICT', 32 | RELAXED='RELAXED') 33 | 34 | OUTCHANNEL = vroom.Specification( 35 | COMMAND='command', 36 | STDOUT='stdout', 37 | STDERR='stderr', 38 | STATUS='status') 39 | 40 | DEFAULT_MODE = vroom.controls.MODE.REGEX 41 | 42 | 43 | def Load(filename): 44 | """Loads a shell file into python space. 45 | 46 | Args: 47 | filename: The shell file to load. 48 | Returns: 49 | The file contents. 50 | Raises: 51 | FakeShellNotWorking 52 | """ 53 | try: 54 | with open(filename, 'rb') as f: 55 | return pickle.load(f) 56 | except IOError: 57 | raise FakeShellNotWorking 58 | 59 | 60 | def Send(filename, data): 61 | """Sends python data to a shell file. 62 | 63 | Args: 64 | filename: The shell file to send to. 65 | data: The python data to send. 66 | """ 67 | with open(filename, 'wb') as f: 68 | pickle.dump(data, f) 69 | 70 | 71 | class Communicator(object): 72 | """Object to communicate with the fake shell.""" 73 | 74 | def __init__(self, filename, env, writer): 75 | self.vroom_env = env 76 | self.writer = writer.syscalls 77 | self.commands_writer = writer.commands 78 | 79 | _, self.control_filename = tempfile.mkstemp() 80 | _, self.log_filename = tempfile.mkstemp() 81 | _, self.error_filename = tempfile.mkstemp() 82 | Send(self.control_filename, []) 83 | Send(self.log_filename, []) 84 | Send(self.error_filename, []) 85 | 86 | self.env = os.environ.copy() 87 | self.env[VROOMFILE_VAR] = filename 88 | self.env[VROOMDIR_VAR] = os.path.dirname(filename) or '.' 89 | self.env[vroom.shell.LOG_FILENAME_VAR] = self.log_filename 90 | self.env[vroom.shell.CONTROL_FILENAME_VAR] = self.control_filename 91 | self.env[vroom.shell.ERROR_FILENAME_VAR] = self.error_filename 92 | 93 | self._copied_logs = 0 94 | 95 | def Control(self, hijacks): 96 | """Tell the shell the system control specifications.""" 97 | existing = Load(self.control_filename) 98 | Send(self.control_filename, existing + hijacks) 99 | 100 | def Verify(self): 101 | """Checks that system output was caught and handled satisfactorily. 102 | 103 | Returns: 104 | Result.Error(vroom.test.Failures[FakeShellFailure]): 105 | If any shell failures were detected. 106 | Result.Success(): Otherwise. 107 | Raises: 108 | FakeShellNotWorking: If it can't load the shell file. 109 | """ 110 | # Copy any new logs into the logger. 111 | logs = Load(self.log_filename) 112 | for log in logs[self._copied_logs:]: 113 | self.writer.Log(log) 114 | self._copied_logs = len(logs) 115 | 116 | failures = [] 117 | 118 | # Check for shell errors. 119 | errors = Load(self.error_filename) 120 | if errors: 121 | failures.append(FakeShellNotWorking(errors)) 122 | 123 | commands_logs = self.commands_writer.Logs() 124 | 125 | # Check that all controls have been handled. 126 | controls = Load(self.control_filename) 127 | if controls: 128 | Send(self.control_filename, []) 129 | missed = controls[0] 130 | if missed.expectation: 131 | failures.append(SystemNotCalled(logs, controls, commands_logs)) 132 | failures.append(NoChanceForResponse(logs, missed, commands_logs)) 133 | 134 | # Check for unexpected calls, if they user is into that. 135 | if self.vroom_env.system_strictness == STRICTNESS.STRICT: 136 | logs = self.writer.Logs() 137 | if [log for log in logs if log.TYPE == vroom.test.LOG.UNEXPECTED]: 138 | failures.append(UnexpectedSystemCalls(logs, commands_logs)) 139 | 140 | if failures: 141 | return Result.Error(vroom.test.Failures(failures)) 142 | else: 143 | return Result.Success() 144 | 145 | 146 | class Hijack(object): 147 | """An object used to tell the fake shell what to do about system calls. 148 | 149 | It can contain a single expectation (of a system call) and any number of 150 | responses (text to return when the expected call is seen). 151 | 152 | If no expectation is given, it will match any command. 153 | If no responses are given, the command will be allowed through the fake shell. 154 | 155 | The Hijack can be 'Open' or 'Closed': we need a way to distinguish 156 | between this: 157 | 158 | $ One 159 | $ Two 160 | 161 | and this: 162 | 163 | $ One 164 | 165 | $ Two 166 | 167 | The former responds "One\\nTwo" to any command. The latter responds "One" to 168 | the first command, whatever it may be, and then "Two" to the next command. 169 | 170 | The solution is that line breaks "Close" an expectation. In this way, we can 171 | tell if a new respones should be part of the previous expectation or part of 172 | a new one. 173 | """ 174 | 175 | def __init__(self, fakecmd, expectation=None, mode=None): 176 | self.closed = False 177 | self.fakecmd = fakecmd 178 | self.response = {} 179 | self.expectation = expectation 180 | self.mode = mode or DEFAULT_MODE 181 | 182 | def Response(self, command): 183 | """Returns the command that should be done in place of the true command. 184 | 185 | This will either be the original command or a call to respond.vroomfaker. 186 | 187 | Args: 188 | command: The vim-requested command. 189 | Returns: 190 | The user-specified command. 191 | """ 192 | if self.expectation is not None: 193 | if not vroom.test.Matches(self.expectation, self.mode, command): 194 | return False 195 | 196 | # We don't want to do this on init because regexes don't repr() as nicely as 197 | # strings do. 198 | if self.expectation and self.mode == vroom.controls.MODE.REGEX: 199 | try: 200 | match_regex = re.compile(self.expectation) 201 | except re.error as e: 202 | raise vroom.ParseError("Can't match command. Invalid regex. %s'" % e) 203 | else: 204 | match_regex = re.compile(r'^.*$') 205 | 206 | # The actual response won't be exactly like the internal response, because 207 | # we've got to do some regex group binding magic. 208 | response = {} 209 | 210 | # Expand all of the responders that want to be bound to the regex. 211 | for channel in ( 212 | OUTCHANNEL.COMMAND, 213 | OUTCHANNEL.STDOUT, 214 | OUTCHANNEL.STDERR): 215 | for line in self.response.get(channel, []): 216 | # We do an re.sub() regardless of whether the control was bound as 217 | # a regex: this forces you to escape consistently between all match 218 | # groups, which will help prevent your tests from breaking if you later 219 | # switch the command matching to regex from verbatim/glob. 220 | try: 221 | line = match_regex.sub(line, command) 222 | except re.error as e: 223 | # 'invalid group reference' is the expected message here. 224 | # Unfortunately the python re module doesn't differentiate its 225 | # exceptions well. 226 | if self.mode != vroom.controls.MODE.REGEX: 227 | raise vroom.ParseError( 228 | 'Substitution error. ' 229 | 'Ensure that matchgroups (such as \\1) are escaped.') 230 | raise vroom.ParseError('Substitution error: %s.' % e) 231 | response.setdefault(channel, []).append(line) 232 | 233 | # The return status can't be regex-bound. 234 | if OUTCHANNEL.STATUS in self.response: 235 | response[OUTCHANNEL.STATUS] = self.response[OUTCHANNEL.STATUS] 236 | 237 | # If we actually want to do anything, call out to the responder. 238 | if response: 239 | return '%s %s' % (self.fakecmd, shlex.quote(json.dumps(response))) 240 | return command 241 | 242 | def Respond(self, line, channel=None): 243 | """Adds a response to this expectation. 244 | 245 | Args: 246 | line: The response to add. 247 | channel: The output channel to respond with 'line' in. 248 | """ 249 | if channel is None: 250 | channel = OUTCHANNEL.STDOUT 251 | if channel == OUTCHANNEL.COMMAND: 252 | self.response.setdefault(OUTCHANNEL.COMMAND, []).append(line) 253 | elif channel == OUTCHANNEL.STDOUT: 254 | self.response.setdefault(OUTCHANNEL.STDOUT, []).append(line) 255 | elif channel == OUTCHANNEL.STDERR: 256 | self.response.setdefault(OUTCHANNEL.STDERR, []).append(line) 257 | elif channel == OUTCHANNEL.STATUS: 258 | if OUTCHANNEL.STATUS in self.response: 259 | raise vroom.ParseError('A system call cannot return two statuses!') 260 | try: 261 | status = int(line) 262 | except ValueError: 263 | raise vroom.ParseError('Returned status must be a number.') 264 | self.response[OUTCHANNEL.STATUS] = status 265 | else: 266 | assert False, 'Unrecognized output channel word.' 267 | 268 | def __repr__(self): 269 | return 'Hijack(%s, %s, %s)' % (self.expectation, self.mode, self.response) 270 | 271 | def __str__(self): 272 | out = '' 273 | # %07s pads things out to match with "COMMAND:" 274 | if self.expectation is not None: 275 | out += ' EXPECT:\t%s (%s mode)\n' % (self.expectation, self.mode) 276 | rejoiner = '\n%07s\t' % '' 277 | if OUTCHANNEL.COMMAND in self.response: 278 | out += 'COMMAND:\t%s\n' % rejoiner.join(self.response[OUTCHANNEL.COMMAND]) 279 | if OUTCHANNEL.STDOUT in self.response: 280 | out += ' STDOUT:\t%s\n' % rejoiner.join(self.response[OUTCHANNEL.STDOUT]) 281 | if OUTCHANNEL.STDERR in self.response: 282 | out += ' STDERR:\t%s\n' % rejoiner.join(self.response[OUTCHANNEL.STDERR]) 283 | if 'status' in self.response: 284 | out += ' STATUS:\t%s' % self.response['status'] 285 | return out.rstrip('\n') 286 | 287 | 288 | class FakeShellNotWorking(vroom.test.Failure): 289 | """Called when the fake shell is not working.""" 290 | 291 | def __init__(self, errors): 292 | self.shell_errors = errors 293 | super(FakeShellNotWorking, self).__init__() 294 | 295 | def __str__(self): 296 | errors_description = '\n'.join(list(map(str, self.shell_errors))) 297 | return 'The fake shell is not working as anticipated:\n' + errors_description 298 | 299 | 300 | class FakeShellFailure(vroom.test.Failure): 301 | """Generic fake shell error. Please raise its implementors.""" 302 | DESCRIPTION = 'System failure' 303 | CONTEXT = 12 304 | 305 | def __init__(self, logs, commands, message=None): 306 | self.syscalls = logs[-self.CONTEXT:] 307 | self.commands = commands 308 | super(FakeShellFailure, self).__init__(message or self.DESCRIPTION) 309 | 310 | 311 | class UnexpectedSystemCalls(FakeShellFailure): 312 | """Raised when a system call is made unexpectedly.""" 313 | DESCRIPTION = 'Unexpected system call.' 314 | 315 | 316 | class SystemNotCalled(FakeShellFailure): 317 | """Raised when an expected system call is not made.""" 318 | DESCRIPTION = 'Expected system call not received.' 319 | 320 | def __init__(self, logs, expectations, commands): 321 | self.expectations = expectations 322 | super(SystemNotCalled, self).__init__(logs, commands) 323 | 324 | 325 | class NoChanceForResponse(FakeShellFailure): 326 | """Raised when no system calls were made, but a response was specified.""" 327 | DESCRIPTION = 'Got no chance to inject response: \n%s' 328 | 329 | def __init__(self, logs, response, commands): 330 | super(NoChanceForResponse, self).__init__(logs, commands, 331 | self.DESCRIPTION % response) 332 | -------------------------------------------------------------------------------- /vroom/controls.py: -------------------------------------------------------------------------------- 1 | """Vroom control block parsing.""" 2 | import re 3 | 4 | import vroom 5 | 6 | # Pylint is not smart enough to notice that all the exceptions here inherit from 7 | # vroom.test.Failure, which is a standard Exception. 8 | # pylint: disable-msg=nonstandard-exception 9 | 10 | OPTION = vroom.Specification( 11 | BUFFER='buffer', 12 | RANGE='range', 13 | MODE='mode', 14 | DELAY='delay', 15 | MESSAGE_STRICTNESS='messages', 16 | SYSTEM_STRICTNESS='system', 17 | OUTPUT_CHANNEL='channel') 18 | 19 | MODE = vroom.Specification( 20 | REGEX='regex', 21 | GLOB='glob', 22 | VERBATIM='verbatim') 23 | 24 | SPECIAL_RANGE = vroom.Specification( 25 | CURRENT_LINE='.') 26 | 27 | REGEX = vroom.Specification( 28 | BUFFER_NUMBER=re.compile(r'^(\d+)$'), 29 | RANGE=re.compile(r'^(\.|\d+)?(?:,(\+)?(\$|\d+)?)?$'), 30 | MODE=re.compile(r'^(%s)$' % '|'.join(MODE.Values())), 31 | DELAY=re.compile(r'^(\d+(?:\.\d+)?)s?$'), 32 | CONTROL_BLOCK=re.compile(r'( .*) \(\s*([%><=\'"\w\d.+,$ ]*)\s*\)$'), 33 | ESCAPED_BLOCK=re.compile(r'( .*) \(&([^)]+)\)$')) 34 | 35 | DEFAULT_MODE = MODE.VERBATIM 36 | 37 | 38 | def SplitLine(line): 39 | """Splits the line controls off of a line. 40 | 41 | >>> SplitLine(' > This is my line (2s)') 42 | (' > This is my line', '2s') 43 | >>> SplitLine(' > This one has no controls') 44 | (' > This one has no controls', None) 45 | >>> SplitLine(' > This has an escaped control (&see)') 46 | (' > This has an escaped control (see)', None) 47 | >>> SplitLine(' world (20,)') 48 | (' world', '20,') 49 | 50 | Args: 51 | line: The line to split 52 | Returns: 53 | (line, control string) 54 | """ 55 | match = REGEX.CONTROL_BLOCK.match(line) 56 | if match: 57 | return match.groups() 58 | unescape = REGEX.ESCAPED_BLOCK.match(line) 59 | if unescape: 60 | return ('%s (%s)' % unescape.groups(), None) 61 | return (line, None) 62 | 63 | 64 | def BufferWord(word): 65 | """Parses a buffer control word. 66 | 67 | >>> BufferWord('2') 68 | 2 69 | >>> BufferWord('not-a-buffer') 70 | Traceback (most recent call last): 71 | ... 72 | vroom.controls.UnrecognizedWord: Unrecognized control word "not-a-buffer" 73 | 74 | Args: 75 | word: The control string. 76 | Returns: 77 | An int. 78 | Raises: 79 | UnrecognizedWord: When the word is not a buffer word. 80 | """ 81 | if REGEX.BUFFER_NUMBER.match(word): 82 | return int(word) 83 | raise UnrecognizedWord(word) 84 | 85 | 86 | def RangeWord(word): 87 | """Parses a range control word. 88 | 89 | >>> RangeWord('.,')[0] == SPECIAL_RANGE.CURRENT_LINE 90 | True 91 | 92 | >>> RangeWord(',+10')[0] is None 93 | True 94 | >>> RangeWord(',+10')[1](3) 95 | 13 96 | 97 | >>> RangeWord('2,$')[0] 98 | 2 99 | >>> RangeWord('2,$')[1]('anything') 100 | 0 101 | 102 | >>> RangeWord('8,10')[0] 103 | 8 104 | >>> RangeWord('8,10')[1](8) 105 | 10 106 | 107 | >>> RangeWord('20,')[0] 108 | 20 109 | 110 | >>> RangeWord('farts') 111 | Traceback (most recent call last): 112 | ... 113 | vroom.controls.UnrecognizedWord: Unrecognized control word "farts" 114 | 115 | Args: 116 | word: The word to parse. 117 | Returns: 118 | (start, start -> end) 119 | Raises: 120 | UnrecognizedWord: When the word is not a range word. 121 | """ 122 | match = REGEX.RANGE.match(word) 123 | if not match: 124 | raise UnrecognizedWord(word) 125 | (start, operator, end) = match.groups() 126 | if start == '.': 127 | start = SPECIAL_RANGE.CURRENT_LINE 128 | elif start: 129 | start = int(start) 130 | if end is None and operator is None: 131 | getend = lambda x: x 132 | elif end == '$': 133 | getend = lambda x: 0 134 | elif operator == '+': 135 | getend = lambda x: x + int(end) 136 | else: 137 | getend = lambda x: int(end) 138 | return (start, getend) 139 | 140 | 141 | def DelayWord(word): 142 | """Parses a delay control word. 143 | 144 | >>> DelayWord('4') 145 | 4.0 146 | >>> DelayWord('4.1s') 147 | 4.1 148 | >>> DelayWord('nope') 149 | Traceback (most recent call last): 150 | ... 151 | vroom.controls.UnrecognizedWord: Unrecognized control word "nope" 152 | 153 | Args: 154 | word: The word to parse. 155 | Returns: 156 | The delay, in milliseconds (as a float) 157 | Raises: 158 | UnrecognizedWord: When the word is not a delay word. 159 | """ 160 | match = REGEX.DELAY.match(word) 161 | if match: 162 | return float(match.groups()[0]) 163 | raise UnrecognizedWord(word) 164 | 165 | 166 | def ModeWord(word): 167 | """Parses a mode control word. 168 | 169 | >>> ModeWord('regex') == MODE.REGEX 170 | True 171 | >>> ModeWord('glob') == MODE.GLOB 172 | True 173 | >>> ModeWord('verbatim') == MODE.VERBATIM 174 | True 175 | >>> ModeWord('nope') 176 | Traceback (most recent call last): 177 | ... 178 | vroom.controls.UnrecognizedWord: Unrecognized control word "nope" 179 | 180 | Args: 181 | word: The word to parse. 182 | Returns: 183 | The mode string, a member of MODE 184 | Raises: 185 | UnrecognizedWord: When the word is not a mode word. 186 | """ 187 | match = REGEX.MODE.match(word) 188 | if match: 189 | return word 190 | raise UnrecognizedWord(word) 191 | 192 | 193 | def MessageWord(word): 194 | """Parses a message strictness level. 195 | 196 | >>> import vroom.messages 197 | >>> MessageWord('STRICT') == vroom.messages.STRICTNESS.STRICT 198 | True 199 | >>> MessageWord('RELAXED') == vroom.messages.STRICTNESS.RELAXED 200 | True 201 | >>> MessageWord('GUESS-ERRORS') == vroom.messages.STRICTNESS.ERRORS 202 | True 203 | >>> MessageWord('nope') 204 | Traceback (most recent call last): 205 | ... 206 | vroom.controls.UnrecognizedWord: Unrecognized control word "nope" 207 | 208 | Args: 209 | word: The word to parse. 210 | Returns: 211 | The strictness, a member of vroom.messages.STRICTNESS 212 | Raises: 213 | UnrecognizedWord: When the word is not a STRICTNESS. 214 | """ 215 | # vroom.controls can't import vroom.messages, because that creates a circular 216 | # dependency both with itself (controls is imported for DEFAULT_MODE) and 217 | # vroom.test. Sorry, pylint 218 | # Pylint, brilliant as usual, thinks that this line redefines 'vroom'. 219 | # pylint: disable-msg=redefined-outer-name 220 | import vroom.messages # pylint: disable-msg=g-import-not-at-top 221 | regex = re.compile(r'^(%s)$' % '|'.join(vroom.messages.STRICTNESS.Values())) 222 | match = regex.match(word) 223 | if match: 224 | return word 225 | raise UnrecognizedWord(word) 226 | 227 | 228 | def SystemWord(word): 229 | """Parses a system strictness level. 230 | 231 | >>> import vroom.shell 232 | >>> SystemWord('STRICT') == vroom.shell.STRICTNESS.STRICT 233 | True 234 | >>> SystemWord('RELAXED') == vroom.shell.STRICTNESS.RELAXED 235 | True 236 | >>> SystemWord('nope') 237 | Traceback (most recent call last): 238 | ... 239 | vroom.controls.UnrecognizedWord: Unrecognized control word "nope" 240 | 241 | Args: 242 | word: The word to parse. 243 | Returns: 244 | The strictness, a member of vroom.shell.STRICTNESS 245 | Raises: 246 | UnrecognizedWord: When the word is not a STRICTNESS. 247 | """ 248 | # vroom.controls can't import vroom.shell, because that creates a circular 249 | # dependency both with itself (controls is imported for DEFAULT_MODE) and 250 | # vroom.test. Sorry, pylint. 251 | # Pylint, brilliant as usual, thinks that this line redefines 'vroom'. 252 | # pylint: disable-msg=redefined-outer-name 253 | import vroom.shell # pylint: disable-msg=g-import-not-at-top 254 | regex = re.compile(r'^(%s)$' % '|'.join(vroom.shell.STRICTNESS.Values())) 255 | match = regex.match(word) 256 | if match: 257 | return word 258 | raise UnrecognizedWord(word) 259 | 260 | 261 | def OutputChannelWord(word): 262 | """Parses a system output channel. 263 | 264 | >>> import vroom.shell 265 | >>> OutputChannelWord('stdout') == vroom.shell.OUTCHANNEL.STDOUT 266 | True 267 | >>> OutputChannelWord('stderr') == vroom.shell.OUTCHANNEL.STDERR 268 | True 269 | >>> OutputChannelWord('command') == vroom.shell.OUTCHANNEL.COMMAND 270 | True 271 | >>> OutputChannelWord('status') == vroom.shell.OUTCHANNEL.STATUS 272 | True 273 | >>> OutputChannelWord('nope') 274 | Traceback (most recent call last): 275 | ... 276 | vroom.controls.UnrecognizedWord: Unrecognized control word "nope" 277 | 278 | Args: 279 | word: The word to parse. 280 | Returns: 281 | The output channel, a member of vroom.shell.OUTCHANNEL 282 | Raises: 283 | UnrecognizedWord: When the word is not an OUTCHANNEL. 284 | """ 285 | # vroom.controls can't import vroom.shell, because that creates a circular 286 | # dependency both with itself (controls is imported for DEFAULT_MODE) and 287 | # vroom.test. Sorry, pylint 288 | # Pylint, brilliant as usual, thinks that this line redefines 'vroom'. 289 | # pylint: disable-msg=redefined-outer-name 290 | import vroom.shell # pylint: disable-msg=g-import-not-at-top 291 | regex = re.compile(r'^(%s)$' % '|'.join(vroom.shell.OUTCHANNEL.Values())) 292 | match = regex.match(word) 293 | if match: 294 | return word 295 | raise UnrecognizedWord(word) 296 | 297 | 298 | OPTION_PARSERS = { 299 | OPTION.BUFFER: BufferWord, 300 | OPTION.RANGE: RangeWord, 301 | OPTION.MODE: ModeWord, 302 | OPTION.DELAY: DelayWord, 303 | OPTION.MESSAGE_STRICTNESS: MessageWord, 304 | OPTION.SYSTEM_STRICTNESS: SystemWord, 305 | OPTION.OUTPUT_CHANNEL: OutputChannelWord, 306 | } 307 | 308 | 309 | def Parse(controls, *options): 310 | """Parses a control block. 311 | 312 | >>> controls = Parse('2 .,+2 regex 4.02s') 313 | >>> (controls['buffer'], controls['mode'], controls['delay']) 314 | (2, 'regex', 4.02) 315 | >>> (controls['range'][0], controls['range'][1](1)) 316 | ('.', 3) 317 | 318 | >>> controls = Parse('1 2', OPTION.BUFFER, OPTION.DELAY) 319 | >>> (controls['buffer'], controls['delay']) 320 | (1, 2.0) 321 | 322 | >>> controls = Parse('1 2', OPTION.DELAY, OPTION.BUFFER) 323 | >>> (controls['buffer'], controls['delay']) 324 | (2, 1.0) 325 | 326 | >>> Parse('1 2 3', OPTION.DELAY, OPTION.BUFFER) 327 | Traceback (most recent call last): 328 | ... 329 | vroom.controls.DuplicatedControl: Duplicated buffer control "3" 330 | 331 | >>> Parse('regex 4.02s', OPTION.MODE) 332 | Traceback (most recent call last): 333 | ... 334 | vroom.controls.UnrecognizedWord: Unrecognized control word "4.02s" 335 | 336 | >>> Parse('STRICT', OPTION.MESSAGE_STRICTNESS) 337 | {'messages': 'STRICT'} 338 | >>> Parse('STRICT', OPTION.SYSTEM_STRICTNESS) 339 | {'system': 'STRICT'} 340 | 341 | Pass in members of OPTION to restrict what types of words are allowed and to 342 | control word precedence. If no options are passed in, all are allowed with 343 | precedence BUFFER, RANGE, MODE, DELAY 344 | 345 | Args: 346 | controls: The control string to parse. 347 | *options: The allowed OPTION type for each word (in order of precedence). 348 | Returns: 349 | A dict with the controls, with OPTION's values for keys. The keys will 350 | always exist, and will be None if the option was not present. 351 | Raises: 352 | ValueError: When the control can not be parsed. 353 | """ 354 | if not options: 355 | options = [OPTION.BUFFER, OPTION.RANGE, OPTION.MODE, OPTION.DELAY] 356 | for option in [o for o in options if o not in OPTION_PARSERS]: 357 | raise ValueError("Can't parse unknown control word: %s" % option) 358 | parsers = [(o, OPTION_PARSERS.get(o)) for o in options] 359 | 360 | result = {o: None for o, _ in parsers} 361 | 362 | def Insert(key, val, word): 363 | if result[key] is not None: 364 | raise DuplicatedControl(option, word) 365 | result[key] = val 366 | 367 | error = None 368 | for word in controls.split(): 369 | for option, parser in parsers: 370 | try: 371 | Insert(option, parser(word), word) 372 | except vroom.ParseError as e: 373 | error = e 374 | else: 375 | break 376 | else: 377 | raise error 378 | 379 | return result 380 | 381 | 382 | class UnrecognizedWord(vroom.ParseError): 383 | """Raised when a control word is not recognized.""" 384 | 385 | def __init__(self, word): 386 | msg = 'Unrecognized control word "%s"' % word 387 | super(UnrecognizedWord, self).__init__(msg) 388 | 389 | 390 | class DuplicatedControl(vroom.ParseError): 391 | """Raised when a control word is duplicated.""" 392 | 393 | def __init__(self, option, word): 394 | msg = 'Duplicated %s control "%s"' % (option, word) 395 | super(DuplicatedControl, self).__init__(msg) 396 | -------------------------------------------------------------------------------- /vroom/actions.py: -------------------------------------------------------------------------------- 1 | """Vroom action parsing (actions are different types of vroom lines).""" 2 | import collections 3 | import vroom 4 | import vroom.controls 5 | 6 | ACTION = vroom.Specification( 7 | COMMENT='comment', 8 | PASS='pass', 9 | INPUT='input', 10 | COMMAND='command', 11 | TEXT='text', 12 | CONTINUATION='continuation', 13 | DIRECTIVE='directive', 14 | MESSAGE='message', 15 | SYSTEM='system', 16 | HIJACK='hijack', 17 | OUTPUT='output', 18 | MACRO='macro') 19 | 20 | DIRECTIVE = vroom.Specification( 21 | CLEAR='clear', 22 | END='end', 23 | MESSAGES='messages', 24 | SYSTEM='system', 25 | MACRO='macro', 26 | ENDMACRO='endmacro', 27 | DO='do') 28 | 29 | DIRECTIVE_PREFIX = ' @' 30 | ENDMACRO = DIRECTIVE_PREFIX + DIRECTIVE.ENDMACRO 31 | EMPTY_LINE_CHECK = ' &' 32 | 33 | # The number of blank lines that equate to a @clear command. 34 | # (Set to None to disable). 35 | BLANK_LINE_CLEAR_COMBO = 3 36 | 37 | UNCONTROLLED_LINE_TYPES = { 38 | ACTION.CONTINUATION: ' |', 39 | } 40 | 41 | DELAY_OPTIONS = (vroom.controls.OPTION.DELAY,) 42 | MODE_OPTIONS = (vroom.controls.OPTION.MODE,) 43 | BUFFER_OPTIONS = (vroom.controls.OPTION.BUFFER,) 44 | HIJACK_OPTIONS = (vroom.controls.OPTION.OUTPUT_CHANNEL,) 45 | OUTPUT_OPTIONS = ( 46 | vroom.controls.OPTION.BUFFER, 47 | vroom.controls.OPTION.RANGE, 48 | vroom.controls.OPTION.MODE, 49 | ) 50 | 51 | CONTROLLED_LINE_TYPES = { 52 | ACTION.INPUT: (' > ', DELAY_OPTIONS), 53 | ACTION.TEXT: (' % ', DELAY_OPTIONS), 54 | ACTION.COMMAND: (' :', DELAY_OPTIONS), 55 | ACTION.MESSAGE: (' ~ ', MODE_OPTIONS), 56 | ACTION.SYSTEM: (' ! ', MODE_OPTIONS), 57 | ACTION.HIJACK: (' $ ', HIJACK_OPTIONS), 58 | ACTION.OUTPUT: (' & ', OUTPUT_OPTIONS), 59 | } 60 | 61 | 62 | def ActionLine(line, state=None): 63 | """Parses a single action line of a vroom file. 64 | 65 | >>> ActionLine('This is a comment.') 66 | ('comment', 'This is a comment.', {}) 67 | >>> ActionLine(' > iHello, world! (2s)') 68 | ('input', 'iHello, world!', {'delay': 2.0}) 69 | >>> ActionLine(' :wqa') 70 | ('command', 'wqa', {'delay': None}) 71 | >>> ActionLine(' % Hello, world!') # doctest: +ELLIPSIS 72 | ('text', 'Hello, world!', ...) 73 | >>> ActionLine(' |To be continued.') 74 | ('continuation', 'To be continued.', {}) 75 | >>> ActionLine(' ~ ERROR(*): (glob)') 76 | ('message', 'ERROR(*):', {'mode': 'glob'}) 77 | >>> ActionLine(' ! system says (regex)') 78 | ('system', 'system says', {'mode': 'regex'}) 79 | >>> ActionLine(' $ I say...') # doctest: +ELLIPSIS 80 | ('hijack', 'I say...', ...) 81 | >>> ActionLine(' $ I say... (stderr)') # doctest: +ELLIPSIS 82 | ('hijack', 'I say...', ...) 83 | >>> ActionLine(' @clear') 84 | ('directive', 'clear', {}) 85 | >>> ActionLine(' @nope') 86 | Traceback (most recent call last): 87 | ... 88 | vroom.ParseError: Unrecognized directive "nope" 89 | >>> state = ParseState([]) 90 | >>> ActionLine(' @macro (abc)', state) 91 | ('macro', None, None) 92 | >>> state.lineno += 1 # need to update the state lineno 93 | >>> ActionLine(' > iHello, world! (2s)', state) 94 | ('macro', None, None) 95 | >>> state.lineno += 1 96 | >>> ActionLine(' :wqa', state) 97 | ('macro', None, None) 98 | >>> state.lineno += 1 99 | >>> ActionLine(' % Hello, world!', state) 100 | ('macro', None, None) 101 | >>> ActionLine(' @endmacro', state) 102 | ('macro', None, None) 103 | >>> state.lines 104 | deque([]) 105 | >>> ActionLine(' @do (abc)', state) 106 | ('macro', None, None) 107 | >>> state.NextLine() # macro lines are added to the front of the queue 108 | (' > iHello, world! (2s)', 0) 109 | >>> state.NextLine() 110 | (' :wqa', 1) 111 | >>> state.NextLine() 112 | (' % Hello, world!', 2) 113 | >>> ActionLine(' & Output!') # doctest: +ELLIPSIS 114 | ('output', 'Output!', ...) 115 | >>> ActionLine(' Simpler output!') # doctest: +ELLIPSIS 116 | ('output', 'Simpler output!', ...) 117 | 118 | Args: 119 | line: The line (string) 120 | state(ParseState): An object keeping track of parse state 121 | Returns: 122 | (ACTION, line, controls) where line is the original line with the newline, 123 | action prefix (' > ', etc.) and control block removed, and controls is 124 | a control dictionary. 125 | Raises: 126 | vroom.ParseError 127 | """ 128 | 129 | # If a macro is currently being recorded, then we must lookahead for the 130 | # @endmacro directive or else it would be included in the macro which is not 131 | # what we want 132 | if state and state.macro_name and not line.startswith(ENDMACRO): 133 | state.macros[state.macro_name].append((line, state.lineno)) 134 | return (ACTION.MACRO, None, None) 135 | 136 | line = line.rstrip('\n') 137 | 138 | # PASS is different from COMMENT in that PASS breaks up output blocks, 139 | # hijack continuations, etc. 140 | if not line: 141 | return (ACTION.PASS, line, {}) 142 | 143 | if not line.startswith(' '): 144 | return (ACTION.COMMENT, line, {}) 145 | 146 | # These lines do not use control blocks. 147 | # NOTE: We currently don't even check for control blocks on these line types, 148 | # preferring to trust the user. We should consider which scenario is more 149 | # common: People wanting a line ending in parentheses without escaping them, 150 | # and so using a continuation, versus people accidentally putting a control 151 | # block where they shouldn't and being surprised when it's ignored. 152 | # Data would be nice. 153 | for linetype, prefix in UNCONTROLLED_LINE_TYPES.items(): 154 | if line.startswith(prefix): 155 | return (linetype, line[len(prefix):], {}) 156 | 157 | # Directives must be parsed in two chunks, as some allow controls blocks and 158 | # some don't. This section is directives with no control blocks. 159 | if line.startswith(DIRECTIVE_PREFIX): 160 | directive = line[len(DIRECTIVE_PREFIX):] 161 | if directive == DIRECTIVE.CLEAR: 162 | return (ACTION.DIRECTIVE, directive, {}) 163 | 164 | line, controls = vroom.controls.SplitLine(line) 165 | 166 | def Controls(options): 167 | return vroom.controls.Parse(controls or '', *options) 168 | 169 | # Here lie directives that have control blocks. 170 | if line.startswith(DIRECTIVE_PREFIX): 171 | directive = line[len(DIRECTIVE_PREFIX):] 172 | if directive == DIRECTIVE.END: 173 | return (ACTION.DIRECTIVE, directive, Controls(BUFFER_OPTIONS)) 174 | elif directive == DIRECTIVE.MESSAGES: 175 | return (ACTION.DIRECTIVE, directive, 176 | Controls((vroom.controls.OPTION.MESSAGE_STRICTNESS,))) 177 | elif directive == DIRECTIVE.SYSTEM: 178 | return (ACTION.DIRECTIVE, directive, 179 | Controls((vroom.controls.OPTION.SYSTEM_STRICTNESS,))) 180 | elif directive == DIRECTIVE.MACRO: 181 | if state.macro_name: 182 | raise vroom.ParseError("Nested macro definitions aren't allowed") 183 | state.macro_name = controls 184 | state.macro_lineno = state.lineno 185 | state.macros[state.macro_name] = [] 186 | return (ACTION.MACRO, None, None) 187 | elif directive == DIRECTIVE.ENDMACRO: 188 | if not state.macro_name: 189 | raise vroom.ParseError('Not defining a macro') 190 | state.macros[state.macro_name] = Macro(state.macros[state.macro_name]) 191 | state.macro_name = None 192 | return (ACTION.MACRO, None, None) 193 | elif directive == DIRECTIVE.DO: 194 | name, kwargs = Macro.ParseCall(controls) 195 | if name not in state.macros: 196 | raise vroom.ParseError('Macro "%s" is not defined' % name) 197 | state.lines.extendleft(state.macros[name].Expand(kwargs)) 198 | return (ACTION.MACRO, None, None) 199 | # Non-controlled directives should be parsed before SplitLineControls. 200 | raise vroom.ParseError('Unrecognized directive "%s"' % directive) 201 | 202 | for linetype, (prefix, options) in CONTROLLED_LINE_TYPES.items(): 203 | if line.startswith(prefix): 204 | return (linetype, line[len(prefix):], Controls(options)) 205 | 206 | # Special output to match empty buffer lines without trailing whitespace. 207 | if line == EMPTY_LINE_CHECK: 208 | return (ACTION.OUTPUT, '', Controls(OUTPUT_OPTIONS)) 209 | 210 | # Default 211 | return (ACTION.OUTPUT, line[2:], Controls(OUTPUT_OPTIONS)) 212 | 213 | 214 | def Parse(lines): 215 | """Parses a vroom file. 216 | 217 | Is actually a generator. 218 | 219 | Args: 220 | lines: An iterable of lines to parse. (A file handle will do.) 221 | Yields: 222 | (Number, ACTION, Line, Control Dictionary) where 223 | Number is the original line number (which may not be the same as the 224 | index in the list if continuation lines were used. It's the line 225 | number of the last relevant continuation.) 226 | ACTION is the ACTION (will never be COMMENT nor CONTINUATION) 227 | Line is the parsed line. 228 | Control Dictionary is the control data. 229 | Raises: 230 | vroom.ParseError with the relevant line number and an error message. 231 | """ 232 | pending = None 233 | pass_count = 0 234 | state = ParseState(lines) 235 | 236 | while state.lines: 237 | line, lineno = state.NextLine() 238 | # ensure the state lineno always matches the real lineno(so it is restored 239 | # after a @do directive 240 | state.lineno = lineno 241 | try: 242 | (linetype, line, control) = ActionLine(line, state) 243 | # Add line number context to all parse errors. 244 | except vroom.ParseError as e: 245 | e.SetLineNumber(lineno) 246 | raise 247 | # Ignore comments during vroom execution. 248 | if linetype == ACTION.MACRO: 249 | continue 250 | if linetype == ACTION.COMMENT: 251 | # Comments break blank-line combos. 252 | pass_count = 0 253 | continue 254 | # Continuation actions are chained to the pending action. 255 | if linetype == ACTION.CONTINUATION: 256 | if pending is None: 257 | raise vroom.ConfigurationError('No command to continue', lineno) 258 | pending = (lineno, pending[1], pending[2] + line, pending[3]) 259 | continue 260 | # Contiguous hijack commands are chained together by newline. 261 | if (pending is not None and 262 | pending[1] == ACTION.HIJACK and 263 | not control and 264 | linetype == ACTION.HIJACK): 265 | pending = (lineno, linetype, '\n'.join((pending[2], line)), pending[3]) 266 | continue 267 | # Flush out any pending commands now that we know there's no continuations. 268 | if pending: 269 | yield pending 270 | pending = None 271 | action = (lineno, linetype, line, control) 272 | # PASS lines can't be continuated. 273 | if linetype == ACTION.PASS: 274 | pass_count += 1 275 | if pass_count == BLANK_LINE_CLEAR_COMBO: 276 | yield (lineno, ACTION.DIRECTIVE, DIRECTIVE.CLEAR, {}) 277 | else: 278 | yield action 279 | else: 280 | pass_count = 0 281 | # Hold on to this line in case it's followed by a continuation. 282 | pending = action 283 | # Flush out any actions still in the queue. 284 | if pending: 285 | yield pending 286 | if state.macro_name: 287 | e = vroom.ParseError('Unfinished macro "%s"' % state.macro_name) 288 | e.SetLineNumber(state.macro_lineno) 289 | raise e 290 | 291 | 292 | class ParseState(object): 293 | def __init__(self, lines): 294 | self.macro_name = None 295 | self.macros = {} 296 | self.lineno = -1 297 | self.lines = collections.deque( 298 | (line, lineno) for lineno, line in enumerate(lines)) 299 | 300 | def NextLine(self): 301 | self.lineno += 1 302 | return self.lines.popleft() 303 | 304 | 305 | class Macro(object): 306 | def __init__(self, lines): 307 | # The lines are need to be reversed so the order will be kept when 308 | # concatenating with deque.extendleft, which is the same as: 309 | # for item in list: deque.appendleft(item). 310 | self.lines = list(reversed(lines)) 311 | 312 | def Expand(self, kwargs): 313 | for line in self.lines: 314 | if kwargs: 315 | yield (line[0].format(**kwargs), line[1]) 316 | else: 317 | yield line 318 | 319 | @classmethod 320 | def ParseCall(self, controls): 321 | """Parses the control section of a @do directive 322 | 323 | >>> name, kwargs = Macro.ParseCall("name, a=1,b=2,c='3'") 324 | >>> name 325 | 'name' 326 | >>> kwargs['a'] 327 | 1 328 | >>> kwargs['b'] 329 | 2 330 | >>> kwargs['c'] 331 | '3' 332 | 333 | Args: 334 | controls: The controls string 335 | Returns: 336 | (name, kwargs) where name is the macro name and kwargs is a dict with 337 | the keyword arguments passed to str.format 338 | """ 339 | parts = controls.split(',') 340 | name = parts[0].strip() 341 | kwargs = {} 342 | for kv in parts[1:]: 343 | k, v = kv.split('=') 344 | kwargs[k.strip()] = eval(v) 345 | return name, kwargs 346 | 347 | -------------------------------------------------------------------------------- /vroom/vim.py: -------------------------------------------------------------------------------- 1 | """Vroom vim management.""" 2 | import ast 3 | from io import StringIO 4 | import json 5 | import re 6 | import subprocess 7 | import tempfile 8 | import time 9 | 10 | 11 | # Regex for quoted python string literal. From pyparsing.quotedString.reString. 12 | QUOTED_STRING_RE = re.compile(r''' 13 | (?:"(?:[^"\n\r\\]|(?:"")|(?:\\x[0-9a-fA-F]+)|(?:\\.))*")| 14 | (?:'(?:[^'\n\r\\]|(?:'')|(?:\\x[0-9a-fA-F]+)|(?:\\.))*') 15 | ''', re.VERBOSE) 16 | 17 | # Vroom has been written such that this data *could* go into a separate .vim 18 | # file, and that would be great. However, python distutils (believe it or not) 19 | # makes it extraordinarily tough to distribute custom files with your python 20 | # modules. It's both difficult to know where they go and difficult to allow them 21 | # to be read. If the user does a sudo install, distutils has no way to make the 22 | # .vim file actually readable and vroom dies from permission errors. 23 | # So screw you, python. I'll just hardcode it. 24 | _, CONFIGFILE = tempfile.mkstemp() 25 | with open(CONFIGFILE, 'w') as configfile: 26 | configfile.write(""" 27 | " Prevents your vroom tests from doing nasty things to your system. 28 | set noswapfile 29 | 30 | " Hidden function to execute a command and return the output. 31 | " Useful for :messages 32 | function! VroomExecute(command) 33 | redir => l:output 34 | silent! execute a:command 35 | redir end 36 | return l:output 37 | endfunction 38 | 39 | " Hidden function to reset a test. 40 | function! VroomClear() 41 | stopinsert 42 | silent! bufdo! bdelete! 43 | endfunction 44 | 45 | " Hidden function to dump an error into vim. 46 | function! VroomDie(output) 47 | let g:vroom_error = a:output 48 | let g:vroom_error .= "\\n:tabedit $VROOMFILE to edit the test file." 49 | let g:vroom_error .= "\\nThis output is saved in g:vroom_error." 50 | let g:vroom_error .= "\\nQuit vim when you're done." 51 | echo g:vroom_error 52 | endfunction 53 | 54 | " Hidden function to kill vim, independent of insert mode. 55 | function! VroomEnd() 56 | qa! 57 | endfunction 58 | """) 59 | 60 | 61 | def DeserializeVimValue(value_str): 62 | """Return string representation of value literal from vim. 63 | 64 | Args: 65 | value_str: A serialized string representing a simple value literal. The 66 | serialization format is just the output of the vimscript string() func. 67 | Raises: 68 | BadVimValue if the value could not be deserialized. 69 | """ 70 | if not value_str: 71 | return None 72 | # Translate some vimscript idioms to python before evaling as python literal. 73 | # Vimscript strings represent backslashes literally. 74 | value_str = value_str.replace('\\', '\\\\').replace('\n', '\\n') 75 | # Replace "''" inside strings with "\'". 76 | def ToVimQuoteEscape(m): 77 | val = m.group(0) 78 | if val.startswith("'"): 79 | return val[:1] + val[1:-1].replace("''", "\\'") + val[-1:] 80 | else: 81 | return val 82 | value_str = re.sub(QUOTED_STRING_RE, ToVimQuoteEscape, value_str) 83 | try: 84 | return ast.literal_eval(value_str) 85 | except SyntaxError: 86 | raise BadVimValue(value_str) 87 | 88 | 89 | class Communicator(object): 90 | """Object to communicate with a vim server.""" 91 | 92 | def __init__(self, args, env, writer): 93 | self.writer = writer.commands 94 | self.args = args 95 | # The order of switches matters. '--clean' will prevent vim from loading any 96 | # plugins from ~/.vim/, but it also sets '-u DEFAULTS'. We supply '-u' after 97 | # to force vim to take our '-u' value (while still avoiding plugins). 98 | self.start_command = [ 99 | 'vim', 100 | '--clean', 101 | '-u', args.vimrc, 102 | '--servername', args.servername, 103 | '-c', 'set shell=' + args.shell, 104 | '-c', 'source %s' % CONFIGFILE] 105 | self.env = env 106 | self._cache = {} 107 | 108 | def Start(self): 109 | """Starts vim.""" 110 | if not self._IsCurrentDisplayUsable(): 111 | # Try using explicit $DISPLAY value. This only affects vim's client/server 112 | # connections and not how console vim appears. 113 | original_display = self.env.get('DISPLAY') 114 | self.env['DISPLAY'] = ':0' 115 | if not self._IsCurrentDisplayUsable(): 116 | # Restore original display value if ":0" doesn't work, either. 117 | if original_display is None: 118 | del self.env['DISPLAY'] 119 | else: 120 | self.env['DISPLAY'] = original_display 121 | # TODO(dbarnett): Try all values from /tmp/.X11-unix/, etc. 122 | 123 | # We do this separately from __init__ so that if it fails, vroom.runner 124 | # still has a _vim attribute it can query for details. 125 | self.process = subprocess.Popen(self.start_command, env=self.env) 126 | time.sleep(self.args.startuptime) 127 | if self.process.poll() is not None and self.process.poll() != 0: 128 | # If vim exited this quickly, it probably means we passed a switch it 129 | # doesn't recognize. Try again without the '--clean' switch since this is 130 | # new in 8.0.1554+. 131 | self.start_command.remove('--clean') 132 | self.process = subprocess.Popen(self.start_command, env=self.env) 133 | time.sleep(self.args.startuptime) 134 | 135 | def _IsCurrentDisplayUsable(self): 136 | """Check whether vim fails using the current configured display.""" 137 | try: 138 | self.Ask('1') 139 | except NoDisplay: 140 | return False 141 | except Quit: 142 | # Any other error means the display setting is fine (assuming vim didn't 143 | # fail before it checked the display). 144 | pass 145 | return True 146 | 147 | def Communicate(self, command, extra_delay=0): 148 | """Sends a command to vim & sleeps long enough for the command to happen. 149 | 150 | Args: 151 | command: The command to send. 152 | extra_delay: Delay in excess of --delay 153 | Raises: 154 | Quit: If vim quit unexpectedly. 155 | """ 156 | self.writer.Log(command) 157 | self.TryToSay([ 158 | 'vim', 159 | '--servername', self.args.servername, 160 | '--remote-send', command]) 161 | self._cache = {} 162 | time.sleep(self.args.delay + extra_delay) 163 | 164 | def Ask(self, expression): 165 | """Asks vim for the result of an expression. 166 | 167 | Args: 168 | expression: The expression to ask for. 169 | Returns: 170 | Return value from vim, or None if vim had no output. 171 | Raises: 172 | Quit if vim quit unexpectedly. 173 | BadVimValue if vim returns a value that can't be deserialized. 174 | """ 175 | try: 176 | out = self.TryToSay([ 177 | 'vim', 178 | '--servername', self.args.servername, 179 | '--remote-expr', 'string(%s)' % expression]) 180 | except ErrorOnExit as e: 181 | if e.error_text.startswith('E449:'): # Invalid expression received 182 | raise InvalidExpression(expression) 183 | raise 184 | # Vim adds a trailing newline to --remote-expr output if there isn't one 185 | # already. 186 | return DeserializeVimValue(out.rstrip()) 187 | 188 | def GetCurrentLine(self): 189 | """Figures out what line the cursor is on. 190 | 191 | Returns: 192 | The cursor's line. 193 | """ 194 | if 'line' not in self._cache: 195 | lineno = self.Ask("line('.')") 196 | try: 197 | self._cache['line'] = lineno 198 | except (ValueError, TypeError): 199 | raise ValueError("Vim lost the cursor, it thinks it's '%s'." % lineno) 200 | return self._cache['line'] 201 | 202 | def GetBufferLines(self, number): 203 | """Gets the lines in the requesed buffer. 204 | 205 | Args: 206 | number: The buffer number to load. SHOULD NOT be a member of 207 | SpecialBuffer, use GetMessages if you want messages. Only works on 208 | real buffers. 209 | Returns: 210 | The buffer lines. 211 | """ 212 | if number not in self._cache: 213 | num = "'%'" if number is None else number 214 | cmd = "getbufline(%s, 1, '$')" % num 215 | self._cache[number] = self.Ask(cmd) 216 | return self._cache[number] 217 | 218 | def GetMessages(self): 219 | """Gets the vim message list. 220 | 221 | Returns: 222 | The message list. 223 | """ 224 | # This prevents GetMessages() from being called twice in a row. 225 | # (When checking a (msg) output line, first we check the messages then we 226 | # load the buffer.) Cleans up --dump-commands a bit. 227 | if 'msg' not in self._cache: 228 | cmd = "VroomExecute('silent! messages')" 229 | # Add trailing newline as workaround for http://bugs.python.org/issue7638. 230 | self._cache['msg'] = (self.Ask(cmd) + '\n').splitlines() 231 | return self._cache['msg'] 232 | 233 | def Clear(self): 234 | self.writer.Log(None) 235 | self.Ask('VroomClear()') 236 | self._cache = {} 237 | 238 | def Output(self, writer): 239 | """Send the writer output to the user.""" 240 | if hasattr(self, 'process'): 241 | buf = StringIO() 242 | writer.Write(buf) 243 | self.Ask('VroomDie({})'.format(VimscriptString(buf.getvalue()))) 244 | buf.close() 245 | 246 | def Quit(self): 247 | """Tries to cleanly quit the vim process. 248 | 249 | Returns: 250 | True if vim successfully quit or wasn't running, False otherwise. 251 | """ 252 | # We might die before the process is even set up. 253 | if hasattr(self, 'process'): 254 | if self.process.poll() is None: 255 | # Evaluate our VroomEnd function as an expression instead of issuing a 256 | # command, which works even if vim isn't in normal mode. 257 | try: 258 | self.Ask('VroomEnd()') 259 | except Quit: 260 | # Probably failed to quit. If vim is still running, we'll return False 261 | # below. 262 | pass 263 | if self.process.poll() is None: 264 | return False 265 | else: 266 | del self.process 267 | return True 268 | 269 | def Kill(self): 270 | """Kills the vim process.""" 271 | # We might die before the process is even set up. 272 | if hasattr(self, 'process'): 273 | if self.process.poll() is None: 274 | self.process.kill() 275 | del self.process 276 | 277 | def TryToSay(self, cmd): 278 | """Execute a given vim process. 279 | 280 | Args: 281 | cmd: The command to send. 282 | Returns: 283 | stdout from vim. 284 | Raises: 285 | Quit: If vim quits unexpectedly. 286 | """ 287 | if hasattr(self, 'process') and self.process.poll() is not None: 288 | raise ServerQuit() 289 | 290 | # Override messages generated by the vim client process (in particular, the 291 | # "No display" message) to be in English so that we can recognise them. 292 | # We do this by setting both LC_ALL (per POSIX) and LANGUAGE (a GNU gettext 293 | # extension) to en_US.UTF-8. (Setting LANG=C would disable localisation 294 | # entirely, but has the bad side-effect of also setting the character 295 | # encoding to ASCII, which breaks when the remote side sends a non-ASCII 296 | # character.) 297 | # 298 | # Note that this does not affect messages from the vim server process, 299 | # which should be matched using error codes as usual. 300 | env = self.env.copy() 301 | env.update({ 302 | 'LANGUAGE': 'en_US.UTF-8', 303 | 'LC_ALL': 'en_US.UTF-8'}) 304 | 305 | out, err = subprocess.Popen( 306 | cmd, 307 | stdout=subprocess.PIPE, 308 | stderr=subprocess.PIPE, 309 | env=env).communicate() 310 | if out is None: 311 | raise Quit('Vim could not respond to query "%s"' % ' '.join(cmd[3:])) 312 | if err: 313 | error_text = err.decode('utf-8').rstrip('\n') 314 | if error_text == 'No display: Send expression failed.': 315 | raise NoDisplay(self.env.get('DISPLAY')) 316 | else: 317 | raise ErrorOnExit(error_text) 318 | return out.decode('utf-8') 319 | 320 | 321 | def VimscriptString(string): 322 | """Escapes & quotes a string for usage as a vimscript string literal. 323 | 324 | Escaped such that \\n will mean newline (in other words double-quoted 325 | vimscript strings are used). 326 | 327 | >>> VimscriptString('Then (s)he said\\n"Hello"') 328 | '"Then (s)he said\\\\n\\\\"Hello\\\\""' 329 | 330 | Args: 331 | string: The string to escape. 332 | Returns: 333 | The escaped string, in double quotes. 334 | """ 335 | return json.dumps(string) 336 | 337 | 338 | def SplitCommand(string): 339 | """Parse out the actual command from the shell command. 340 | 341 | Vim will say things like 342 | /path/to/$SHELL -c '(cmd args) < /tmp/in > /tmp/out' 343 | We want to parse out just the 'cmd args' part. This is a bit difficult, 344 | because we don't know precisely what vim's shellescaping will do. 345 | 346 | This is a rather simple parser that grabs the first parenthesis block 347 | and knows enough to avoid nested parens, escaped parens, and parens in 348 | strings. 349 | 350 | NOTE: If the user does :call system('echo )'), *vim will error*. This is 351 | a bug in vim. We do not need to be sane in this case. 352 | 353 | >>> cmd, rebuild = SplitCommand('ls') 354 | >>> cmd 355 | 'ls' 356 | >>> rebuild('mycmd') 357 | 'mycmd' 358 | >>> cmd, rebuild = SplitCommand('(echo ")") < /tmp/in > /tmp/out') 359 | >>> cmd 360 | 'echo ")"' 361 | >>> rebuild('mycmd') 362 | '(mycmd) < /tmp/in > /tmp/out' 363 | >>> SplitCommand('(cat /foo/bar > /tmp/whatever)')[0] 364 | 'cat /foo/bar > /tmp/whatever' 365 | >>> SplitCommand("(echo '()')")[0] 366 | "echo '()'" 367 | 368 | Args: 369 | string: The command string to parse. 370 | Returns: 371 | (relevant, rebuild): A tuple containing the actual command issued by the 372 | user and a function to rebuild the full command that vim wants to execute. 373 | """ 374 | if string.startswith('('): 375 | stack = [] 376 | for i, char in enumerate(string): 377 | if stack and stack[-1] == '\\': 378 | stack.pop() 379 | elif stack and stack[-1] == '"' and char == '"': 380 | stack.pop() 381 | elif stack and stack[-1] == "'" and char == "'": 382 | stack.pop() 383 | elif stack and stack[-1] == '(' and char == ')': 384 | stack.pop() 385 | elif char in '\\\'("': 386 | stack.append(char) 387 | if not stack: 388 | return (string[1:i], lambda cmd: (string[0] + cmd + string[i:])) 389 | return (string, lambda cmd: cmd) 390 | 391 | 392 | class Quit(Exception): 393 | """Raised when vim seems to have quit unexpectedly.""" 394 | 395 | # Whether vroom should exit immediately or finish running other tests. 396 | is_fatal = False 397 | 398 | 399 | class ServerQuit(Quit): 400 | """Raised when the vim server process quits unexpectedly.""" 401 | 402 | is_fatal = True 403 | 404 | def __str__(self): 405 | return 'Vim server process quit unexpectedly' 406 | 407 | 408 | class ErrorOnExit(Quit): 409 | """Raised when a vim process unexpectedly prints to stderr.""" 410 | 411 | def __init__(self, error_text): 412 | super(ErrorOnExit, self).__init__() 413 | self.error_text = error_text 414 | 415 | def __str__(self): 416 | return 'Vim quit unexpectedly, saying "{}"'.format(self.error_text) 417 | 418 | 419 | class InvalidExpression(Quit): 420 | def __init__(self, expression): 421 | super(InvalidExpression, self).__init__() 422 | self.expression = expression 423 | 424 | def __str__(self): 425 | return 'Vim failed to evaluate expression "{}"'.format(self.expression) 426 | 427 | 428 | class NoDisplay(Quit): 429 | """Raised when vim can't access the defined display properly.""" 430 | 431 | def __init__(self, display_value): 432 | super(NoDisplay, self).__init__() 433 | self.display_value = display_value 434 | 435 | def __str__(self): 436 | if self.display_value is not None: 437 | display_context = 'display "{}"'.format(self.display_value) 438 | else: 439 | display_context = 'unspecified display' 440 | return 'Vim failed to access {}'.format(display_context) 441 | 442 | 443 | class BadVimValue(Exception): 444 | """Raised when vroom fails to deserialize a serialized value from vim.""" 445 | 446 | is_fatal = False 447 | 448 | def __init__(self, value): 449 | self.value = value 450 | 451 | def __str__(self): 452 | return 'Vroom failed to deserialize vim value {!r}'.format(self.value) 453 | -------------------------------------------------------------------------------- /vroom/output.py: -------------------------------------------------------------------------------- 1 | """Vroom output manager. It's harder than it looks.""" 2 | import sys 3 | import traceback 4 | 5 | import vroom 6 | import vroom.buffer 7 | import vroom.color 8 | import vroom.controls 9 | import vroom.messages 10 | import vroom.shell 11 | import vroom.test 12 | import vroom.vim 13 | 14 | # In lots of places in this file we use the name 'file' to mean 'a file'. 15 | # We do this so that Logger.Print can have an interface consistent with 16 | # python3's print. 17 | # pylint: disable-msg=redefined-builtin 18 | 19 | 20 | STATUS = vroom.Specification( 21 | PASS='PASS', 22 | ERROR='ERROR', 23 | FAIL='FAIL') 24 | 25 | 26 | COLORS = { 27 | STATUS.PASS: vroom.color.GREEN, 28 | STATUS.ERROR: vroom.color.YELLOW, 29 | STATUS.FAIL: vroom.color.RED, 30 | } 31 | 32 | 33 | class Writer(object): 34 | """An output writer for a single vroom test file.""" 35 | 36 | def __init__(self, filename, args): 37 | """Creatse the writer. 38 | 39 | Args: 40 | filename: The file to be tested. 41 | args: The command line arguments. 42 | """ 43 | self.messages = MessageLogger(args.dump_messages, args.verbose, args.color) 44 | self.commands = CommandLogger(args.dump_commands, args.verbose, args.color) 45 | self.syscalls = SyscallLogger(args.dump_syscalls, args.verbose, args.color) 46 | self.actions = ActionLogger(args.out, args.verbose, args.color) 47 | self._filename = filename 48 | 49 | def Begin(self, lines): 50 | """Begins testing the file. 51 | 52 | Args: 53 | lines: The lines of the file. 54 | """ 55 | self.actions.Open(lines) 56 | 57 | def Write(self, file=None): 58 | """Writes output for the file. 59 | 60 | Must be done after all tests are completed, because stdout will be used to 61 | run vim during the duration of the tests. 62 | 63 | Args: 64 | file: An alternate file handle to write to. Default None. 65 | """ 66 | self.actions.Print( 67 | self._filename, 68 | color=(vroom.color.BOLD, vroom.color.TEAL), 69 | file=file) 70 | self.actions.Print('', verbose=True, file=file) 71 | self.actions.Write(self._filename, file=file) 72 | extra = self.messages.Write(self._filename, file=file) 73 | extra = self.commands.Write(self._filename, file=file) or extra 74 | extra = self.syscalls.Write(self._filename, file=file) or extra 75 | self.actions.Print('', file=file, verbose=None if extra else True) 76 | stats = self.Stats() 77 | plural = '' if stats['total'] == 1 else 's' 78 | self.actions.Print( 79 | 'Ran %d test%s in %s.' % (stats['total'], plural, self._filename), 80 | end=' ') 81 | self.actions.PutStat(stats, STATUS.PASS, '%d passing', file=file) 82 | self.actions.PutStat(stats, STATUS.ERROR, '%d errored', file=file) 83 | self.actions.PutStat(stats, STATUS.FAIL, '%d failed', file=file, end='.\n') 84 | if stats['total'] == 0: 85 | self.actions.Print( 86 | 'WARNING', color=vroom.color.YELLOW, file=file, end=': ') 87 | self.actions.Print('NO TESTS RUN', file=file) 88 | 89 | def Stats(self): 90 | """Statistics on this test. Should be called after the test has completed. 91 | 92 | Returns: 93 | A dict containing STATUS fields. 94 | """ 95 | if not hasattr(self, '_stats'): 96 | stats = self.actions.Results() 97 | stats['total'] = ( 98 | stats[STATUS.PASS] + stats[STATUS.ERROR] + stats[STATUS.FAIL]) 99 | self._stats = stats 100 | return self._stats 101 | 102 | def Status(self): 103 | """Returns the status of this test. 104 | 105 | Returns: 106 | PASS for Passed. 107 | ERROR for Errored (no failures). 108 | FAIL for Failure. 109 | """ 110 | stats = self.Stats() 111 | if stats[STATUS.FAIL]: 112 | return STATUS.FAIL 113 | elif stats[STATUS.ERROR]: 114 | return STATUS.ERROR 115 | return STATUS.PASS 116 | 117 | 118 | class Logger(object): 119 | """Generic writer sublogger. 120 | 121 | We can't use one logger because sometimes we have different writing components 122 | (system logs, message logs, command logs) that are all writing interleavedly 123 | but which should output in separate blocks. These Loggers handle one of those 124 | separate blocks. 125 | 126 | Thus, it must queue all messages and write them all at once at the end. 127 | """ 128 | HEADER = None 129 | EMPTY = None 130 | 131 | def __init__(self, file, verbose, color): 132 | """Creates the logger. 133 | 134 | Args: 135 | file: The file to log to. 136 | verbose: Whether or not to write verbosely. 137 | color: A function used to color text. 138 | """ 139 | self._verbose = verbose 140 | self._color = color 141 | self._file = file 142 | self._queue = [] 143 | 144 | def Log(self, message): 145 | """Records a message. 146 | 147 | Args: 148 | message: The message to record. 149 | """ 150 | self._queue.append(message) 151 | 152 | def Logs(self): 153 | """The currently recorded messages. 154 | 155 | Mostly used when exceptions try to 156 | figure out why they happened. 157 | 158 | Returns: 159 | A list of messages. 160 | """ 161 | return self._queue 162 | 163 | def Print(self, message, verbose=None, color=None, end='\n', file=None): 164 | """Prints a message to the file. 165 | 166 | Args: 167 | message: The message to print. 168 | verbose: When verbose is not None, message is only printed if verbose is 169 | the same as --verbose. 170 | color: vroom.color escape code (or a tuple of the same) to color the 171 | message with. 172 | end: The line-end (use '' to suppress the default newline). 173 | file: Alternate file handle to use. 174 | """ 175 | handle = file or self._file 176 | if (verbose is None) or (verbose == self._verbose): 177 | if handle == sys.stdout and color is not None: 178 | if not isinstance(color, tuple): 179 | color = (color,) 180 | message = self._color(message, *color) 181 | handle.write(message + end) 182 | 183 | def Write(self, filename, file=None): 184 | """Writes all messages. 185 | 186 | Args: 187 | filename: Vroom file that was tested, for use in the header. 188 | file: An alternate file to write to. Will only redirect to the 189 | alternate file if it was going to write to a file in the first place. 190 | Returns: 191 | Whether or not output was written. 192 | """ 193 | if self._file is None: 194 | return False 195 | file = file or self._file 196 | lines = list(self.Finalize(self._queue)) 197 | self.Print('', file=file) 198 | if lines: 199 | if self.HEADER: 200 | header = self.HEADER % {'filename': filename} 201 | self.Print(header, end=':\n', file=file) 202 | for line in lines: 203 | self.Print(line.rstrip('\n'), file=file) 204 | elif self.EMPTY: 205 | empty = self.EMPTY % {'filename': filename} 206 | self.Print(empty, end='.\n', file=file) 207 | return True 208 | 209 | def Finalize(self, queue): 210 | """Used to pre-process all messages after the test and before display. 211 | 212 | Args: 213 | queue: The message queue 214 | Returns: 215 | The modified message queue. 216 | """ 217 | return PrefixWithIndex(queue) 218 | 219 | 220 | class MessageLogger(Logger): 221 | """A logger for vim messages.""" 222 | 223 | HEADER = 'Vim messages during %(filename)s' 224 | EMPTY = 'There were no vim messages during %(filename)s' 225 | 226 | 227 | class CommandLogger(Logger): 228 | """A logger for vim commands.""" 229 | 230 | HEADER = 'Commands sent to vim during %(filename)s' 231 | EMPTY = 'No commands were sent to vim during %(filename)s' 232 | 233 | 234 | class SyscallLogger(Logger): 235 | """A logger for vim system commands & calls.""" 236 | 237 | HEADER = 'System call log during %(filename)s' 238 | EMPTY = 'No syscalls made by vim during %(filename)s' 239 | 240 | def Finalize(self, queue): 241 | return map(str, queue) 242 | 243 | 244 | class ActionLogger(Logger): 245 | """A logger for the main test output. 246 | 247 | Prints the test file in verbose mode. Prints minimal pass/failure information 248 | otherwise. 249 | """ 250 | 251 | def __init__(self, *args, **kwargs): 252 | self._opened = False 253 | super(ActionLogger, self).__init__(*args, **kwargs) 254 | 255 | def Open(self, lines): 256 | """Opens the action logger for a specific vroom file. 257 | 258 | Must be called before logging can begin. 259 | 260 | Args: 261 | lines: The file's lines. 262 | """ 263 | self._lines = lines 264 | self._nextline = 0 265 | self._passed = 0 266 | self._errored = 0 267 | self._failed = 0 268 | self._opened = True 269 | 270 | def Write(self, filename, file=None): 271 | """Writes the test output. Should be called after vim has shut down. 272 | 273 | Args: 274 | filename: Used in the header. 275 | file: Alt file to redirect output to. Output will only be redirected 276 | if it was going to be output in the first place. 277 | Raises: 278 | NoTestRunning: if called too soon. 279 | """ 280 | if not self._opened: 281 | raise NoTestRunning 282 | self.ExecutedUpTo(len(self._lines) - 1) 283 | for (line, args, kwargs) in self._queue: 284 | self.Print(line, *args, file=file, **kwargs) 285 | 286 | def PutStat(self, stats, stat, fmt, **kwargs): 287 | """Writes a stat to output. 288 | 289 | Will color the stat if the stat is non-zero and the output file is stdout. 290 | 291 | Args: 292 | stats: A dict with all the stats. 293 | stat: The STATUS to check. 294 | fmt: What to say about the stat. 295 | **kwargs: Passed on to print. 296 | """ 297 | assert stat in stats and stat in COLORS 298 | num = stats[stat] 299 | kwargs.setdefault('end', ', ') 300 | if num: 301 | kwargs['color'] = COLORS[stat] 302 | self.Print(fmt % num, **kwargs) 303 | 304 | def Queue(self, message, *args, **kwargs): 305 | """Queues a single line for writing to the output file. 306 | 307 | Args: 308 | message: Will eventually be written. 309 | *args: Will be passed to Print. 310 | **kwargs: Will be passed to Print. 311 | """ 312 | self._queue.append((message, args, kwargs)) 313 | 314 | def Log(self, result, lineno, error=None): 315 | """Logs a test result. 316 | 317 | Args: 318 | result: The vroom.test.RESULT 319 | lineno: The line where the error occured. 320 | error: The exception if vroom.test.isBad(result) 321 | Raises: 322 | NoTestRunning: if called too soon. 323 | """ 324 | self.Tally(result) 325 | self.ExecutedUpTo(lineno) 326 | if error is not None: 327 | self._Error(result, error, lineno) 328 | 329 | def Tally(self, result): 330 | """Tallies the result. 331 | 332 | Args: 333 | result: A vroom.test.result 334 | """ 335 | if result == vroom.test.RESULT.PASSED: 336 | self._passed += 1 337 | if result == vroom.test.RESULT.ERROR: 338 | self._errored += 1 339 | if result == vroom.test.RESULT.FAILED: 340 | self._failed += 1 341 | 342 | def ExecutedUpTo(self, lineno): 343 | """Print output put to a given line number. 344 | 345 | This really only matters in --verbose mode where the file is printed as the 346 | tests run. 347 | 348 | Args: 349 | lineno: The line to print up to. 350 | """ 351 | if self._verbose: 352 | for i, line in enumerate(self._lines[self._nextline:lineno + 1]): 353 | number = self.Lineno(self._nextline + i) 354 | self.Queue('%s %s' % (number, line.rstrip('\n'))) 355 | self._nextline = lineno + 1 356 | 357 | def Lineno(self, lineno): 358 | """The string version of a line number, zero-padded as appropriate. 359 | 360 | Args: 361 | lineno: The line number 362 | Returns: 363 | The zero-padded string. 364 | """ 365 | numberifier = '%%0%dd' % len(str(len(self._lines))) 366 | return numberifier % (lineno + 1) 367 | 368 | def Error(self, result, error): 369 | """Logs an error that didn't occur at a specific line. 370 | 371 | (Vim didn't start, etc.) 372 | 373 | Args: 374 | result: The vroom.test.RESULT. 375 | error: The exception. 376 | """ 377 | self.Tally(result) 378 | self._Error(result, error) 379 | 380 | def _Error(self, result, error, lineno=None): 381 | """Prints an error message. Used by both Log and Error on bad results. 382 | 383 | Args: 384 | result: The vroom.test.RESULT. 385 | error: The execption. 386 | lineno: The place that the error occured, if known. 387 | """ 388 | self.Queue('------------------------------------------------', verbose=True) 389 | if result == vroom.test.RESULT.ERROR: 390 | self.Queue(result.upper(), color=COLORS[STATUS.ERROR], end='') 391 | else: 392 | self.Queue(result.upper(), color=COLORS[STATUS.FAIL], end='') 393 | if lineno is not None: 394 | self.Queue(' on line %s' % self.Lineno(lineno), verbose=False, end='') 395 | self.Queue(': ', end='') 396 | self.Queue(str(error)) 397 | # Print extra context about the error. 398 | # Python isinstance is freeking pathological: isinstance(foo, Foo) can 399 | # change depending upon how you import Foo. Instead of dealing with that 400 | # mess, we ducktype exceptions. 401 | # Also, python can't do real closures, which is why contexted is a list. 402 | contexted = [False] 403 | 404 | def QueueContext(attr, writer, *args): 405 | value = None 406 | if hasattr(error, attr): 407 | value = getattr(error, attr) 408 | elif hasattr(error, 'GetFlattenedFailures'): 409 | for f in error.GetFlattenedFailures(): 410 | if hasattr(f, attr): 411 | value = getattr(f, attr) 412 | break 413 | if value is None: 414 | return 415 | contexted[0] = True 416 | self.Queue('') 417 | writer(value, self.Queue, *args) 418 | 419 | QueueContext('messages', ErrorMessageContext) 420 | QueueContext('context', ErrorBufferContext) 421 | 422 | if lineno is not None: 423 | stripped = self._lines[lineno][2:] 424 | line = '\nFailed command on line %s:\n%s' % ( 425 | self.Lineno(lineno), stripped) 426 | self.Queue(line, end='', verbose=False) 427 | 428 | QueueContext('expectations', ErrorShellQueue) 429 | QueueContext('syscalls', ErrorSystemCalls) 430 | QueueContext('commands', ErrorCommandContext) 431 | 432 | if contexted[0]: 433 | self.Queue('', verbose=False) 434 | self.Queue('------------------------------------------------', verbose=True) 435 | 436 | def Results(self): 437 | """The test results. 438 | 439 | Returns: 440 | A dict containing STATUS.PASS, STATUS.ERROR, and STATUS.FAIL. 441 | """ 442 | return { 443 | STATUS.PASS: self._passed, 444 | STATUS.ERROR: self._errored, 445 | STATUS.FAIL: self._failed, 446 | } 447 | 448 | def Exception(self, exctype, exception, tb): 449 | """Prints out an unexpected exception with stack info. 450 | 451 | Should only be used when vroom encounters an error in its own programming. 452 | We don't ever want real users to see these. 453 | 454 | Args: 455 | exctype: The exception type. 456 | exception: The exception. 457 | tb: The traceback. 458 | """ 459 | self.Tally(vroom.test.RESULT.ERROR) 460 | self.Queue('------------------------------------------------', verbose=True) 461 | self.Queue('') 462 | self.Queue('ERROR', color=COLORS[STATUS.ERROR], end='') 463 | self.Queue(': ', end='') 464 | self.Queue(''.join(traceback.format_exception(exctype, exception, tb))) 465 | self.Queue('') 466 | if hasattr(exception, 'shell_errors'): 467 | ErrorShellErrors(exception.shell_errors, self.Queue) 468 | self.Queue('') 469 | self.Queue('------------------------------------------------', verbose=True) 470 | 471 | 472 | def WriteBackmatter(writers, args): 473 | """Writes the backmatter (# tests run, etc.) for a group of writers. 474 | 475 | Args: 476 | writers: The writers 477 | args: The command line args. 478 | """ 479 | if len(writers) == 1: 480 | # No need to summarize, we'd be repeating ourselves. 481 | return 482 | count = 0 483 | total = 0 484 | passed = 0 485 | errored = 0 486 | args.out.write(args.color('\nVr', vroom.color.VIOLET)) 487 | for writer in writers: 488 | count += 1 489 | total += writer.Stats()['total'] 490 | status = writer.Status() 491 | if status == STATUS.PASS: 492 | passed += 1 493 | elif status == STATUS.ERROR: 494 | errored += 1 495 | args.out.write(args.color('o', COLORS[status])) 496 | args.out.write(args.color('m\n', vroom.color.VIOLET)) 497 | plural = '' if total == 1 else 's' 498 | args.out.write('Ran %d test%s in %d files. ' % (total, plural, count)) 499 | if passed == count: 500 | args.out.write('Everything is ') 501 | args.out.write(args.color('OK', COLORS[STATUS.PASS])) 502 | args.out.write('.') 503 | else: 504 | args.out.write(args.color('%d passed' % passed, COLORS[STATUS.PASS])) 505 | args.out.write(', ') 506 | args.out.write(args.color('%d errored' % errored, COLORS[STATUS.ERROR])) 507 | args.out.write(', ') 508 | failed = count - passed - errored 509 | args.out.write(args.color('%d failed' % failed, COLORS[STATUS.FAIL])) 510 | args.out.write('\n') 511 | 512 | 513 | def PrefixWithIndex(logs): 514 | """Prefixes a bunch of lines with their index. 515 | 516 | Indicies are zero-padded so that everything aligns nicely. 517 | If there's a None log it's skipped and a linebreak is output. 518 | Trailing None logs are ignored. 519 | 520 | >>> list(PrefixWithIndex(['a', 'a'])) 521 | ['1\\ta', '2\\ta'] 522 | >>> list(PrefixWithIndex(['a' for _ in range(10)]))[:2] 523 | ['01\\ta', '02\\ta'] 524 | >>> list(PrefixWithIndex(['a', None, 'a'])) 525 | ['1\\ta', '', '2\\ta'] 526 | 527 | Args: 528 | logs: The lines to index. 529 | Yields: 530 | The indexed lines. 531 | """ 532 | # Makes sure we don't accidentally modify the real logs. 533 | # Also, makes the code not break if someone passes us a generator. 534 | logs = list(logs) 535 | while logs and logs[-1] is None: 536 | logs.pop() 537 | # Gods, I love this line. It creates a formatter that pads a number out to 538 | # match the largest number necessary to index all the non-null lines in logs. 539 | numberifier = '%%0%dd' % len(str(len(list(filter(bool, logs))))) 540 | adjustment = 0 541 | for (i, log) in enumerate(logs): 542 | if log is None: 543 | adjustment += 1 544 | yield '' 545 | else: 546 | index = numberifier % (i + 1 - adjustment) 547 | yield '%s\t%s' % (index, log) 548 | 549 | 550 | def ErrorContextPrinter(header, empty, modifier=None, singleton=None): 551 | """Creates a function that prints extra error data. 552 | 553 | Args: 554 | header: What to print before the data. 555 | empty: What to print when there's no data. 556 | modifier: Optional, run on all the data before printing. 557 | singleton: Optional, what to print when there's only one datum. 558 | Returns: 559 | Function that takes (data, printer) and prints the data using the printer. 560 | """ 561 | 562 | def WriteExtraData(data, printer): 563 | data = list(modifier(data) if modifier else data) 564 | if data: 565 | if not singleton or len(data) > 1: 566 | printer(header, end=':\n') 567 | for datum in data: 568 | if datum is None: 569 | printer('') 570 | else: 571 | printer(str(datum)) 572 | else: 573 | printer(singleton % data[0]) 574 | else: 575 | printer(empty) 576 | 577 | return WriteExtraData 578 | 579 | 580 | # Pylint isn't smart enough to notice that these are all generated functions. 581 | ErrorMessageContext = ErrorContextPrinter( # pylint: disable-msg=g-bad-name 582 | 'Messages', 583 | 'There were no messages.', 584 | modifier=None, 585 | singleton='Message was "%s"') 586 | 587 | ErrorCommandContext = ErrorContextPrinter( # pylint: disable-msg=g-bad-name 588 | 'Last few commands (most recent last) were', 589 | 'No relevant commands found.') 590 | 591 | ErrorSystemCalls = ErrorContextPrinter( # pylint: disable-msg=g-bad-name 592 | 'Recent system logs are', 593 | 'No system calls received. Perhaps your --shell is broken?') 594 | 595 | ErrorShellQueue = ErrorContextPrinter( # pylint: disable-msg=g-bad-name 596 | 'Queued system controls are', 597 | 'No system commands expected.') 598 | 599 | ErrorShellErrors = ErrorContextPrinter( # pylint: disable-msg=g-bad-name 600 | 'Shell error list', 601 | 'Shell had no chance to log errors.', 602 | modifier=PrefixWithIndex) 603 | 604 | 605 | def ErrorBufferContext(context, printer): 606 | """Prints the buffer data relevant to an error. 607 | 608 | Args: 609 | context: The buffer context. 610 | printer: A function to do the printing. 611 | """ 612 | if context is None: 613 | printer('No vim buffer was loaded.') 614 | return 615 | 616 | # Find out what buffer we're printing from. 617 | if context['buffer'] is None: 618 | printer('Checking the current buffer.', end='', verbose=True) 619 | else: 620 | printer('Checking buffer %s.' % context['buffer'], end='', verbose=True) 621 | printer(' Relevant buffer data:', verbose=True) 622 | printer('Found:', verbose=False) 623 | 624 | # Print the relevant buffer lines 625 | (start, end) = (context['start'], context['end']) 626 | # Empty buffer. 627 | if not context['data']: 628 | printer('An empty buffer.') 629 | return 630 | 631 | buflines = list(PrefixWithIndex(context['data'])) 632 | # They're looking at a specific line. 633 | if end > start: 634 | for i, bufline in enumerate(buflines[start:end]): 635 | if context['line'] == i + start: 636 | printer(bufline + ' <<<<', color=vroom.color.BOLD) 637 | else: 638 | printer(bufline) 639 | # They're looking at the whole buffer. 640 | else: 641 | for bufline in buflines: 642 | printer(bufline) 643 | 644 | 645 | class NoTestRunning(ValueError): 646 | """Raised when a logger is asked to log before the test begins.""" 647 | 648 | def __init__(self): 649 | """Creates the exception.""" 650 | super(NoTestRunning, self).__init__( 651 | 'Please run a vroom test before outputting results.') 652 | --------------------------------------------------------------------------------