1# -*- coding: utf-8 -*-
2import glob
3import os
4
5from .lexer import lex
6from .analyzer import analyze, enter_block_ctx
7from .errors import NgxParserDirectiveError
8
9# map of external / third-party directives to a parse function
10EXTERNAL_PARSERS = {}
11
12
13# TODO: raise special errors for invalid "if" args
14def _prepare_if_args(stmt):
15    """Removes parentheses from an "if" directive's arguments"""
16    args = stmt['args']
17    if args and args[0].startswith('(') and args[-1].endswith(')'):
18        args[0] = args[0][1:].lstrip()
19        args[-1] = args[-1][:-1].rstrip()
20        start = int(not args[0])
21        end = len(args) - int(not args[-1])
22        args[:] = args[start:end]
23
24
25def parse(filename, onerror=None, catch_errors=True, ignore=(), single=False,
26        comments=False, strict=False, combine=False, check_ctx=True,
27        check_args=True):
28    """
29    Parses an nginx config file and returns a nested dict payload
30
31    :param filename: string contianing the name of the config file to parse
32    :param onerror: function that determines what's saved in "callback"
33    :param catch_errors: bool; if False, parse stops after first error
34    :param ignore: list or tuple of directives to exclude from the payload
35    :param combine: bool; if True, use includes to create a single config obj
36    :param single: bool; if True, including from other files doesn't happen
37    :param comments: bool; if True, including comments to json payload
38    :param strict: bool; if True, unrecognized directives raise errors
39    :param check_ctx: bool; if True, runs context analysis on directives
40    :param check_args: bool; if True, runs arg count analysis on directives
41    :returns: a payload that describes the parsed nginx config
42    """
43    config_dir = os.path.dirname(filename)
44
45    payload = {
46        'status': 'ok',
47        'errors': [],
48        'config': [],
49    }
50
51    # start with the main nginx config file/context
52    includes = [(filename, ())]  # stores (filename, config context) tuples
53    included = {filename: 0} # stores {filename: array index} map
54
55    def _handle_error(parsing, e):
56        """Adds representaions of an error to the payload"""
57        file = parsing['file']
58        error = str(e)
59        line = getattr(e, 'lineno', None)
60
61        parsing_error = {'error': error, 'line': line}
62        payload_error = {'file': file, 'error': error, 'line': line}
63        if onerror is not None:
64            payload_error['callback'] = onerror(e)
65
66        parsing['status'] = 'failed'
67        parsing['errors'].append(parsing_error)
68
69        payload['status'] = 'failed'
70        payload['errors'].append(payload_error)
71
72    def _parse(parsing, tokens, ctx=(), consume=False):
73        """Recursively parses nginx config contexts"""
74        fname = parsing['file']
75        parsed = []
76
77        # parse recursively by pulling from a flat stream of tokens
78        for token, lineno, quoted in tokens:
79            comments_in_args = []
80
81            # we are parsing a block, so break if it's closing
82            if token == '}' and not quoted:
83                break
84
85            # if we are consuming, then just continue until end of context
86            if consume:
87                # if we find a block inside this context, consume it too
88                if token == '{' and not quoted:
89                    _parse(parsing, tokens, consume=True)
90                continue
91
92            # the first token should always(?) be an nginx directive
93            directive = token
94
95            if combine:
96                stmt = {
97                    'file': fname,
98                    'directive': directive,
99                    'line': lineno,
100                    'args': []
101                }
102            else:
103                stmt = {
104                    'directive': directive,
105                    'line': lineno,
106                    'args': []
107                }
108
109            # if token is comment
110            if directive.startswith('#') and not quoted:
111                if comments:
112                    stmt['directive'] = '#'
113                    stmt['comment'] = token[1:]
114                    parsed.append(stmt)
115                continue
116
117            # TODO: add external parser checking and handling
118
119            # parse arguments by reading tokens
120            args = stmt['args']
121            token, __, quoted = next(tokens)  # disregard line numbers of args
122            while token not in ('{', ';', '}') or quoted:
123                if token.startswith('#') and not quoted:
124                    comments_in_args.append(token[1:])
125                else:
126                    stmt['args'].append(token)
127
128                token, __, quoted = next(tokens)
129
130            # consume the directive if it is ignored and move on
131            if stmt['directive'] in ignore:
132                # if this directive was a block consume it too
133                if token == '{' and not quoted:
134                    _parse(parsing, tokens, consume=True)
135                continue
136
137            # prepare arguments
138            if stmt['directive'] == 'if':
139                _prepare_if_args(stmt)
140
141            try:
142                # raise errors if this statement is invalid
143                analyze(
144                    fname=fname, stmt=stmt, term=token, ctx=ctx, strict=strict,
145                    check_ctx=check_ctx, check_args=check_args
146                )
147            except NgxParserDirectiveError as e:
148                if catch_errors:
149                    _handle_error(parsing, e)
150
151                    # if it was a block but shouldn't have been then consume
152                    if e.strerror.endswith(' is not terminated by ";"'):
153                        if token != '}' and not quoted:
154                            _parse(parsing, tokens, consume=True)
155                        else:
156                            break
157
158                    # keep on parsin'
159                    continue
160                else:
161                    raise e
162
163            # add "includes" to the payload if this is an include statement
164            if not single and stmt['directive'] == 'include':
165                pattern = args[0]
166                if not os.path.isabs(args[0]):
167                    pattern = os.path.join(config_dir, args[0])
168
169                stmt['includes'] = []
170
171                # get names of all included files
172                if glob.has_magic(pattern):
173                    fnames = glob.glob(pattern)
174                    fnames.sort()
175                else:
176                    try:
177                        # if the file pattern was explicit, nginx will check
178                        # that the included file can be opened and read
179                        open(str(pattern)).close()
180                        fnames = [pattern]
181                    except Exception as e:
182                        fnames = []
183                        e.lineno = stmt['line']
184                        if catch_errors:
185                            _handle_error(parsing, e)
186                        else:
187                            raise e
188
189                for fname in fnames:
190                    # the included set keeps files from being parsed twice
191                    # TODO: handle files included from multiple contexts
192                    if fname not in included:
193                        included[fname] = len(includes)
194                        includes.append((fname, ctx))
195                    index = included[fname]
196                    stmt['includes'].append(index)
197
198            # if this statement terminated with '{' then it is a block
199            if token == '{' and not quoted:
200                inner = enter_block_ctx(stmt, ctx)  # get context for block
201                stmt['block'] = _parse(parsing, tokens, ctx=inner)
202
203            parsed.append(stmt)
204
205            # add all comments found inside args after stmt is added
206            for comment in comments_in_args:
207                comment_stmt = {
208                    'directive': '#',
209                    'line': stmt['line'],
210                    'args': [],
211                    'comment': comment
212                }
213                parsed.append(comment_stmt)
214
215        return parsed
216
217    # the includes list grows as "include" directives are found in _parse
218    for fname, ctx in includes:
219        tokens = lex(fname)
220        parsing = {
221            'file': fname,
222            'status': 'ok',
223            'errors': [],
224            'parsed': []
225        }
226        try:
227            parsing['parsed'] = _parse(parsing, tokens, ctx=ctx)
228        except Exception as e:
229            _handle_error(parsing, e)
230
231        payload['config'].append(parsing)
232
233    if combine:
234        return _combine_parsed_configs(payload)
235    else:
236        return payload
237
238
239def _combine_parsed_configs(old_payload):
240    """
241    Combines config files into one by using include directives.
242
243    :param old_payload: payload that's normally returned by parse()
244    :return: the new combined payload
245    """
246    old_configs = old_payload['config']
247
248    def _perform_includes(block):
249        for stmt in block:
250            if 'block' in stmt:
251                stmt['block'] = list(_perform_includes(stmt['block']))
252            if 'includes' in stmt:
253                for index in stmt['includes']:
254                    config = old_configs[index]['parsed']
255                    for stmt in _perform_includes(config):
256                        yield stmt
257            else:
258                yield stmt  # do not yield include stmt itself
259
260    combined_config = {
261        'file': old_configs[0]['file'],
262        'status': 'ok',
263        'errors': [],
264        'parsed': []
265    }
266
267    for config in old_configs:
268        combined_config['errors'] += config.get('errors', [])
269        if config.get('status', 'ok') == 'failed':
270            combined_config['status'] = 'failed'
271
272    first_config = old_configs[0]['parsed']
273    combined_config['parsed'] += _perform_includes(first_config)
274
275    combined_payload = {
276        'status': old_payload.get('status', 'ok'),
277        'errors': old_payload.get('errors', []),
278        'config': [combined_config]
279    }
280    return combined_payload
281
282
283def register_external_parser(parser, directives):
284    """
285    :param parser: parser function
286    :param directives: list of directive strings
287    :return:
288    """
289    for directive in directives:
290        EXTERNAL_PARSERS[directive] = parser
291