1# -*- coding: utf-8 -*-
2#
3# Picard, the next-generation MusicBrainz tagger
4#
5# Copyright (C) 2006-2009, 2012 Lukáš Lalinský
6# Copyright (C) 2007 Javier Kohen
7# Copyright (C) 2008-2011, 2014-2015, 2018-2020 Philipp Wolfer
8# Copyright (C) 2009 Carlin Mangar
9# Copyright (C) 2009 Nikolai Prokoschenko
10# Copyright (C) 2011-2012 Michael Wiencek
11# Copyright (C) 2012 Chad Wilson
12# Copyright (C) 2012 stephen
13# Copyright (C) 2012, 2014, 2017 Wieland Hoffmann
14# Copyright (C) 2013-2014, 2017-2020 Laurent Monin
15# Copyright (C) 2014, 2017 Sophist-UK
16# Copyright (C) 2016-2017 Sambhav Kothari
17# Copyright (C) 2016-2017 Ville Skyttä
18# Copyright (C) 2017-2018 Antonio Larrosa
19# Copyright (C) 2018 Calvin Walton
20# Copyright (C) 2018 virusMac
21# Copyright (C) 2020 Bob Swift
22#
23# This program is free software; you can redistribute it and/or
24# modify it under the terms of the GNU General Public License
25# as published by the Free Software Foundation; either version 2
26# of the License, or (at your option) any later version.
27#
28# This program is distributed in the hope that it will be useful,
29# but WITHOUT ANY WARRANTY; without even the implied warranty of
30# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
31# GNU General Public License for more details.
32#
33# You should have received a copy of the GNU General Public License
34# along with this program; if not, write to the Free Software
35# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
36
37from collections.abc import MutableSequence
38from queue import LifoQueue
39
40from picard.metadata import (
41    MULTI_VALUED_JOINER,
42    Metadata,
43)
44from picard.plugin import ExtensionPoint
45
46
47class ScriptError(Exception):
48    pass
49
50
51class ScriptParseError(ScriptError):
52    def __init__(self, stackitem, message):
53        super().__init__(
54            "{prefix:s}: {message:s}".format(
55                prefix=str(stackitem),
56                message=message
57            )
58        )
59
60
61class ScriptEndOfFile(ScriptParseError):
62    def __init__(self, stackitem):
63        super().__init__(
64            stackitem,
65            "Unexpected end of script"
66        )
67
68
69class ScriptSyntaxError(ScriptParseError):
70    pass
71
72
73class ScriptUnknownFunction(ScriptParseError):
74    def __init__(self, stackitem):
75        super().__init__(
76            stackitem,
77            "Unknown function '{name}'".format(name=stackitem.name)
78        )
79
80
81class ScriptRuntimeError(ScriptError):
82    def __init__(self, stackitem, message='Unknown error'):
83        super().__init__(
84            "{prefix:s}: {message:s}".format(
85                prefix=str(stackitem),
86                message=message
87            )
88        )
89
90
91class StackItem:
92    def __init__(self, line, column, name=None):
93        self.line = line
94        self.column = column
95        if name is None:
96            self.name = None
97        else:
98            self.name = '$' + name
99
100    def __str__(self):
101        if self.name is None:
102            return '{line:d}:{column:d}'.format(
103                line=self.line,
104                column=self.column
105            )
106        else:
107            return '{line:d}:{column:d}:{name}'.format(
108                line=self.line,
109                column=self.column,
110                name=self.name
111            )
112
113
114class ScriptText(str):
115
116    def eval(self, state):
117        return self
118
119
120def normalize_tagname(name):
121    if name.startswith('_'):
122        return "~" + name[1:]
123    return name
124
125
126class ScriptVariable(object):
127
128    def __init__(self, name):
129        self.name = name
130
131    def __repr__(self):
132        return '<ScriptVariable %%%s%%>' % self.name
133
134    def eval(self, state):
135        return state.context.get(normalize_tagname(self.name), "")
136
137
138class ScriptFunction(object):
139
140    def __init__(self, name, args, parser, column=0, line=0):
141        self.stackitem = StackItem(line, column, name)
142        try:
143            argnum_bound = parser.functions[name].argcount
144            argcount = len(args)
145            if argnum_bound:
146                too_few_args = argcount < argnum_bound.lower
147                if argnum_bound.upper is not None:
148                    if argnum_bound.lower == argnum_bound.upper:
149                        expected = "exactly %i" % argnum_bound.lower
150                    else:
151                        expected = "between %i and %i" % (argnum_bound.lower, argnum_bound.upper)
152                    too_many_args = argcount > argnum_bound.upper
153                else:
154                    expected = "at least %i" % argnum_bound.lower
155                    too_many_args = False
156
157                if too_few_args or too_many_args:
158                    raise ScriptSyntaxError(
159                        self.stackitem,
160                        "Wrong number of arguments for $%s: Expected %s, got %i"
161                        % (name, expected, argcount)
162                    )
163        except KeyError:
164            raise ScriptUnknownFunction(self.stackitem)
165
166        self.name = name
167        self.args = args
168
169    def __repr__(self):
170        return "<ScriptFunction $%s(%r)>" % (self.name, self.args)
171
172    def eval(self, parser):
173        try:
174            function_registry_item = parser.functions[self.name]
175        except KeyError:
176            raise ScriptUnknownFunction(self.stackitem)
177
178        if function_registry_item.eval_args:
179            args = [arg.eval(parser) for arg in self.args]
180        else:
181            args = self.args
182        parser._function_stack.put(self.stackitem)
183        # Save return value to allow removing function from the stack on successful completion
184        return_value = function_registry_item.function(parser, *args)
185        parser._function_stack.get()
186        return return_value
187
188
189class ScriptExpression(list):
190
191    def eval(self, state):
192        return "".join([item.eval(state) for item in self])
193
194
195def isidentif(ch):
196    return ch.isalnum() or ch == '_'
197
198
199class ScriptParser(object):
200
201    r"""Tagger script parser.
202
203Grammar:
204  text       ::= [^$%] | '\$' | '\%' | '\(' | '\)' | '\,'
205  argtext    ::= [^$%(),] | '\$' | '\%' | '\(' | '\)' | '\,'
206  identifier ::= [a-zA-Z0-9_]
207  variable   ::= '%' identifier '%'
208  function   ::= '$' identifier '(' (argument (',' argument)*)? ')'
209  expression ::= (variable | function | text)*
210  argument   ::= (variable | function | argtext)*
211"""
212
213    _function_registry = ExtensionPoint(label='function_registry')
214    _cache = {}
215
216    def __init__(self):
217        self._function_stack = LifoQueue()
218
219    def __raise_eof(self):
220        raise ScriptEndOfFile(StackItem(line=self._y, column=self._x))
221
222    def __raise_char(self, ch):
223        raise ScriptSyntaxError(StackItem(line=self._y, column=self._x), "Unexpected character '%s'" % ch)
224
225    def read(self):
226        try:
227            ch = self._text[self._pos]
228        except IndexError:
229            return None
230        else:
231            self._pos += 1
232            self._px = self._x
233            self._py = self._y
234            if ch == '\n':
235                self._line = self._pos
236                self._x = 1
237                self._y += 1
238            else:
239                self._x += 1
240        return ch
241
242    def unread(self):
243        self._pos -= 1
244        self._x = self._px
245        self._y = self._py
246
247    def parse_arguments(self):
248        results = []
249        while True:
250            result, ch = self.parse_expression(False)
251            results.append(result)
252            if ch == ')':
253                # Only an empty expression as first argument
254                # is the same as no argument given.
255                if len(results) == 1 and results[0] == []:
256                    return []
257                return results
258
259    def parse_function(self):
260        start = self._pos
261        column = self._x - 2     # Set x position to start of function name ($)
262        line = self._y
263        while True:
264            ch = self.read()
265            if ch == '(':
266                name = self._text[start:self._pos-1]
267                if name not in self.functions:
268                    raise ScriptUnknownFunction(StackItem(line, column, name))
269                return ScriptFunction(name, self.parse_arguments(), self, column, line)
270            elif ch is None:
271                self.__raise_eof()
272            elif not isidentif(ch):
273                self.__raise_char(ch)
274
275    def parse_variable(self):
276        begin = self._pos
277        while True:
278            ch = self.read()
279            if ch == '%':
280                return ScriptVariable(self._text[begin:self._pos-1])
281            elif ch is None:
282                self.__raise_eof()
283            elif not isidentif(ch) and ch != ':':
284                self.__raise_char(ch)
285
286    def parse_text(self, top):
287        text = []
288        while True:
289            ch = self.read()
290            if ch == "\\":
291                ch = self.read()
292                if ch == 'n':
293                    text.append('\n')
294                elif ch == 't':
295                    text.append('\t')
296                elif ch not in "$%(),\\":
297                    self.__raise_char(ch)
298                else:
299                    text.append(ch)
300            elif ch is None:
301                break
302            elif not top and ch == '(':
303                self.__raise_char(ch)
304            elif ch in '$%' or (not top and ch in ',)'):
305                self.unread()
306                break
307            else:
308                text.append(ch)
309        return ScriptText("".join(text))
310
311    def parse_expression(self, top):
312        tokens = ScriptExpression()
313        while True:
314            ch = self.read()
315            if ch is None:
316                if top:
317                    break
318                else:
319                    self.__raise_eof()
320            elif not top and ch in ',)':
321                break
322            elif ch == '$':
323                tokens.append(self.parse_function())
324            elif ch == '%':
325                tokens.append(self.parse_variable())
326            else:
327                self.unread()
328                tokens.append(self.parse_text(top))
329        return (tokens, ch)
330
331    def load_functions(self):
332        self.functions = {}
333        for name, item in ScriptParser._function_registry:
334            self.functions[name] = item
335
336    def parse(self, script, functions=False):
337        """Parse the script."""
338        self._text = script
339        self._pos = 0
340        self._px = self._x = 1
341        self._py = self._y = 1
342        self._line = 0
343        if not functions:
344            self.load_functions()
345        return self.parse_expression(True)[0]
346
347    def eval(self, script, context=None, file=None):
348        """Parse and evaluate the script."""
349        self.context = context if context is not None else Metadata()
350        self.file = file
351        self.load_functions()
352        key = hash(script)
353        if key not in ScriptParser._cache:
354            ScriptParser._cache[key] = self.parse(script, True)
355        return ScriptParser._cache[key].eval(self)
356
357
358class MultiValue(MutableSequence):
359    def __init__(self, parser, multi, separator):
360        self.parser = parser
361        if isinstance(separator, ScriptExpression):
362            self.separator = separator.eval(self.parser)
363        else:
364            self.separator = separator
365        if (self.separator == MULTI_VALUED_JOINER
366            and len(multi) == 1
367            and isinstance(multi[0], ScriptVariable)):
368            # Convert ScriptExpression containing only a single variable into variable
369            self._multi = self.parser.context.getall(normalize_tagname(multi[0].name))
370        else:
371            # Fall-back to converting to a string and splitting if haystack is an expression
372            # or user has overridden the separator character.
373            evaluated_multi = multi.eval(self.parser)
374            if not evaluated_multi:
375                self._multi = []
376            elif self.separator:
377                self._multi = evaluated_multi.split(self.separator)
378            else:
379                self._multi = [evaluated_multi]
380
381    def __len__(self):
382        return len(self._multi)
383
384    def __getitem__(self, key):
385        return self._multi[key]
386
387    def __setitem__(self, key, value):
388        self._multi[key] = value
389
390    def __delitem__(self, key):
391        del self._multi[key]
392
393    def insert(self, index, value):
394        return self._multi.insert(index, value)
395
396    def __repr__(self):
397        return '%s(%r, %r, %r)' % (self.__class__.__name__, self.parser, self._multi, self.separator)
398
399    def __str__(self):
400        return self.separator.join([x for x in self if x])
401