1# This file is part of the Frescobaldi project, http://www.frescobaldi.org/
2#
3# Copyright (c) 2011 - 2014 by Wilbert Berendsen
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 2
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
18# See http://www.gnu.org/licenses/ for more information.
19
20"""
21Analyze text to determine suitable completions.
22"""
23
24
25import re
26import os
27
28import ly.lex as lx
29import ly.lex.lilypond as lp
30import ly.lex.scheme as scm
31import ly.words
32import tokeniter
33
34from . import completiondata
35from . import documentdata
36
37
38class Analyzer(object):
39    """Analyzes text at some cursor position and gives suitable completions."""
40    def analyze(self, cursor):
41        """Do the analyzing work; set the attributes column and model."""
42        self.cursor = cursor
43        block = cursor.block()
44        self.column = column = cursor.position() - block.position()
45        self.text = text = block.text()[:column]
46        self.model = None
47
48        # make a list of tokens exactly ending at the cursor position
49        # and let state follow
50        state = self.state = tokeniter.state(block)
51        tokens = self.tokens = []
52        for t in tokeniter.tokens(cursor.block()):
53            if t.end > column:
54                # cut off the last token and run the parser on it
55                tokens.extend(state.tokens(text, t.pos))
56                break
57            tokens.append(t)
58            state.follow(t)
59            if t.end == column:
60                break
61
62        self.last = tokens[-1] if tokens else ''
63        self.lastpos = self.last.pos if self.last else column
64
65        parser = state.parser()
66
67        # Map the parser class to a group of tests to return the model.
68        # Try the tests until a model is returned.
69        try:
70            tests = self.tests[parser.__class__]
71        except KeyError:
72            return
73        else:
74            for function in tests:
75                model = function(self)
76                if model:
77                    self.model = model
78                    return
79
80    def completions(self, cursor):
81        """Analyzes text at cursor and returns a tuple (position, model).
82
83        The position is an integer specifying the column in the line where the last
84        text starts that should be completed.
85
86        The model list the possible completions. If the model is None, there are no
87        suitable completions.
88
89        This function does its best to return extremely meaningful completions
90        for the context the cursor is in.
91
92        """
93        self.analyze(cursor)
94        return self.column, self.model
95
96    def document_cursor(self):
97        """Return the current QTextCursor, to harvest info from its document.
98
99        By default this is simply the cursor given on analyze() or completions()
100        but you can override this method to provide another cursor. This can
101        be useful when the completion occurs in a small QTextDocument, which is
102        in fact a part of the main document.
103
104        """
105        return self.cursor
106
107    def tokenclasses(self):
108        """Return the list of classes of the tokens."""
109        return list(map(type, self.tokens))
110
111    def backuntil(self, *classes):
112        """Move self.column back until a token of *classes is encountered."""
113        for t in self.tokens[::-1]:
114            if isinstance(t, classes):
115                break
116            self.column = t.pos
117
118    # Test functions that return a model or None
119    def toplevel(self):
120        """LilyPond toplevel document contents."""
121        self.backuntil(lx.Space)
122        return completiondata.lilypond_toplevel
123        # maybe: check if behind \version or \language
124
125    def book(self):
126        """\\book {"""
127        self.backuntil(lx.Space)
128        cursor = self.document_cursor()
129        return documentdata.doc(cursor.document()).bookcommands(cursor)
130
131    def bookpart(self):
132        """\\bookpart {"""
133        self.backuntil(lx.Space)
134        cursor = self.document_cursor()
135        return documentdata.doc(cursor.document()).bookpartcommands(cursor)
136
137    def score(self):
138        """\\score {"""
139        self.backuntil(lx.Space)
140        cursor = self.document_cursor()
141        return documentdata.doc(cursor.document()).scorecommands(cursor)
142
143    def tweak(self):
144        """complete property after \\tweak"""
145        try:
146            i = self.tokens.index('\\tweak')
147        except ValueError:
148            return
149        tokens = self.tokens[i+1:]
150        tokenclasses = self.tokenclasses()[i+1:]
151        if tokenclasses == [lx.Space, lp.SchemeStart]:
152            self.column -= 1
153            return completiondata.lilypond_all_grob_properties
154        elif tokenclasses == [lx.Space, lp.SchemeStart, scm.Quote]:
155            self.column -= 2
156            return completiondata.lilypond_all_grob_properties
157        elif tokenclasses[:-1] == [lx.Space, lp.SchemeStart, scm.Quote]:
158            self.column = self.lastpos - 2
159            return completiondata.lilypond_all_grob_properties
160        # 2.18-style [GrobName.]propertyname tweak
161        if lp.GrobName in tokenclasses:
162            self.backuntil(lx.Space, lp.DotPath)
163            return completiondata.lilypond_grob_properties(tokens[1], False)
164        if tokens:
165            self.backuntil(lx.Space)
166            return completiondata.lilypond_all_grob_properties_and_grob_names
167
168    def key(self):
169        """complete mode argument of '\\key'"""
170        tokenclasses = self.tokenclasses()
171        if '\\key' in self.tokens[-5:-2] and lp.Note in tokenclasses[-3:]:
172            if self.last.startswith('\\'):
173                self.column = self.lastpos
174            return completiondata.lilypond_modes
175
176    def clef(self):
177        """complete \\clef names"""
178        if '\\clef' in self.tokens[-4:-1]:
179            self.backuntil(lx.Space, lp.StringQuotedStart)
180            return completiondata.lilypond_clefs
181
182    def repeat(self):
183        """complete \\repeat types"""
184        if '\\repeat' in self.tokens[-4:-1]:
185            self.backuntil(lx.Space, lp.StringQuotedStart)
186            return completiondata.lilypond_repeat_types
187
188    def language(self):
189        """complete \\language "name" """
190        if '\\language' in self.tokens[-4:-1]:
191            self.backuntil(lp.StringQuotedStart)
192            return completiondata.language_names
193
194    def include(self):
195        """complete \\include """
196        if '\\include' in self.tokens[-4:-2]:
197            self.backuntil(lp.StringQuotedStart)
198            sep = '/' # Even on Windows, LilyPond uses the forward slash
199            dir = self.last[:self.last.rfind(sep)] if sep in self.last else None
200            cursor = self.document_cursor()
201            return documentdata.doc(cursor.document()).includenames(cursor, dir)
202
203    def general_music(self):
204        """fall back: generic music commands and user-defined commands."""
205        if self.last.startswith('\\'):
206            self.column = self.lastpos
207        cursor = self.document_cursor()
208        return documentdata.doc(cursor.document()).musiccommands(cursor)
209
210    def lyricmode(self):
211        """Commands inside lyric mode."""
212        if self.last.startswith('\\'):
213            self.column = self.lastpos
214        cursor = self.document_cursor()
215        return documentdata.doc(cursor.document()).lyriccommands(cursor)
216
217    def music_glyph(self):
218        r"""Complete \markup \musicglyph names."""
219        try:
220            i = self.tokens.index('\\musicglyph', -5, -3)
221        except ValueError:
222            return
223        for t, cls in zip(self.tokens[i:], (
224            lp.MarkupCommand, lx.Space, lp.SchemeStart, scm.StringQuotedStart, scm.String)):
225            if type(t) is not cls:
226                return
227        if i + 4 < len(self.tokens):
228            self.column = self.tokens[i + 4].pos
229        return completiondata.music_glyphs
230
231    def midi_instrument(self):
232        """Complete midiInstrument = #"... """
233        try:
234            i = self.tokens.index('midiInstrument', -7, -2)
235        except ValueError:
236            return
237        if self.last != '"':
238            self.column = self.lastpos
239        return completiondata.midi_instruments
240
241    def font_name(self):
242        """Complete #'font-name = #"..."""
243        try:
244            i = self.tokens.index('font-name', -7, -3)
245        except ValueError:
246            return
247        if self.last != '"':
248            self.column = self.lastpos
249        return completiondata.font_names()
250
251    def scheme_word(self):
252        """Complete scheme word from scheme functions, etc."""
253        if isinstance(self.last, scm.Word):
254            self.column = self.lastpos
255            cursor = self.document_cursor()
256            return documentdata.doc(cursor.document()).schemewords()
257
258    def markup(self):
259        """\\markup {"""
260        if self.last.startswith('\\'):
261            if (self.last[1:] not in ly.words.markupcommands
262                and self.last != '\\markup'):
263                self.column = self.lastpos
264            else:
265                return completiondata.lilypond_markup_commands
266        else:
267            m = re.search(r'\w+$', self.last)
268            if m:
269                self.column = self.lastpos + m.start()
270        cursor = self.document_cursor()
271        return documentdata.doc(cursor.document()).markup(cursor)
272
273    def markup_top(self):
274        """\\markup ... in music or toplevel"""
275        if self.last.startswith('\\') and isinstance(self.last,
276            (ly.lex.lilypond.MarkupCommand, ly.lex.lilypond.MarkupUserCommand)):
277            self.column = self.lastpos
278            cursor = self.document_cursor()
279            return documentdata.doc(cursor.document()).markup(cursor)
280
281    def header(self):
282        """\\header {"""
283        if '=' in self.tokens[-3:] or self.last.startswith('\\'):
284            if self.last.startswith('\\'):
285                self.column = self.lastpos
286            return completiondata.lilypond_markup
287        if self.last[:1].isalpha():
288            self.column = self.lastpos
289        return completiondata.lilypond_header_variables
290
291    def paper(self):
292        """\\paper {"""
293        if '=' in self.tokens[-3:] or self.last.startswith('\\'):
294            if self.last.startswith('\\'):
295                self.column = self.lastpos
296            return completiondata.lilypond_markup
297        if self.last[:1].isalpha():
298            self.column = self.lastpos
299        return completiondata.lilypond_paper_variables
300
301    def layout(self):
302        """\\layout {"""
303        self.backuntil(lx.Space)
304        return completiondata.lilypond_layout_variables
305
306    def midi(self):
307        """\\midi {"""
308        self.backuntil(lx.Space)
309        return completiondata.lilypond_midi_variables
310
311    def engraver(self):
312        """Complete engraver names."""
313        cmd_in = lambda tokens: '\\remove' in tokens or '\\consists' in tokens
314        if isinstance(self.state.parser(), lp.ParseString):
315            if not cmd_in(self.tokens[-5:-2]):
316                return
317            if self.last != '"':
318                if '"' not in self.tokens[-2:-1]:
319                    return
320                self.column = self.lastpos
321            return completiondata.lilypond_engravers
322        if cmd_in(self.tokens[-3:-1]):
323            self.backuntil(lx.Space)
324            return completiondata.lilypond_engravers
325
326    def context_variable_set(self):
327        if '=' in self.tokens[-4:]:
328            if isinstance(self.last, scm.Word):
329                self.column = self.lastpos
330                cursor = self.document_cursor()
331                return documentdata.doc(cursor.document()).schemewords()
332            if self.last.startswith('\\'):
333                self.column = self.lastpos
334            return completiondata.lilypond_markup
335
336    def context(self):
337        self.backuntil(lx.Space)
338        return completiondata.lilypond_context_contents
339
340    def with_(self):
341        self.backuntil(lx.Space)
342        return completiondata.lilypond_with_contents
343
344    def translator(self):
345        """complete context name after \\new, \\change or \\context in music"""
346        for t in self.tokens[-2::-1]:
347            if isinstance(t, lp.ContextName):
348                return
349            elif isinstance(t, lp.Translator):
350                break
351        self.backuntil(lx.Space)
352        return completiondata.lilypond_contexts
353
354    def override(self):
355        """\\override and \\revert"""
356        tokenclasses = self.tokenclasses()
357        try:
358            # check if there is a GrobName in the last 5 tokens
359            i = tokenclasses.index(lp.GrobName, -5)
360        except ValueError:
361            # not found, then complete Contexts and or Grobs
362            # (only if we are in the override parser and there's no "=")
363            if isinstance(self.state.parser(), scm.ParseScheme):
364                return
365            self.backuntil(lp.DotPath, lx.Space)
366            if (isinstance(self.state.parsers()[1], (
367                    lp.ParseWith,
368                    lp.ParseContext,
369                    ))
370                or lp.DotPath in tokenclasses):
371                return completiondata.lilypond_grobs
372            return completiondata.lilypond_contexts_and_grobs
373        # yes, there is a GrobName at i
374        count = len(self.tokens) - i - 1 # tokens after grobname
375        if count == 0:
376            self.column = self.lastpos
377            return completiondata.lilypond_grobs
378        elif count >= 2:
379            # set the place of the scheme-start "#" as the column
380            self.column = self.tokens[i+2].pos
381        test = [lx.Space, lp.SchemeStart, scm.Quote, scm.Word]
382        if tokenclasses[i+1:] == test[:count]:
383            return completiondata.lilypond_grob_properties(self.tokens[i])
384        self.backuntil(lp.DotPath, lx.Space)
385        return completiondata.lilypond_grob_properties(self.tokens[i], False)
386
387    def revert(self):
388        """test for \\revert in general music expressions
389
390        (because the revert parser drops out of invalid constructs, which happen
391        during typing).
392
393        """
394        if '\\revert' in self.tokens:
395            return self.override()
396
397    def set_unset(self):
398        """\\set and \\unset"""
399        tokenclasses = self.tokenclasses()
400        self.backuntil(lx.Space, lp.DotPath)
401        if lp.ContextProperty in tokenclasses and isinstance(self.last, lx.Space):
402            return # fall back to music?
403        elif lp.DotPath in tokenclasses:
404            return completiondata.lilypond_context_properties
405        return completiondata.lilypond_contexts_and_properties
406
407    def markup_override(self):
408        """test for \\markup \\override inside scheme"""
409        try:
410            i = self.tokens.index('\\override', -6, -4)
411        except ValueError:
412            return
413        for t, cls in zip(self.tokens[i:], (
414            lp.MarkupCommand, lx.Space, lp.SchemeStart, scm.Quote, scm.OpenParen)):
415            if type(t) is not cls:
416                return
417        if len(self.tokens) > i + 5:
418            self.column = self.lastpos
419        return completiondata.lilypond_markup_properties
420
421    def scheme_other(self):
422        """test for other scheme words"""
423        if isinstance(self.last, (
424            lp.SchemeStart,
425            scm.OpenParen,
426            scm.Word,
427            )):
428            if isinstance(self.last, scm.Word):
429                self.column = self.lastpos
430            cursor = self.document_cursor()
431            return documentdata.doc(cursor.document()).schemewords()
432
433    def accidental_style(self):
434        """test for \accidentalStyle"""
435        try:
436            i = self.tokens.index("\\accidentalStyle")
437        except ValueError:
438            return
439        self.backuntil(lx.Space, lp.DotPath)
440        tokens = self.tokens[i+1:]
441        tokenclasses = self.tokenclasses()[i+1:]
442        try:
443            i = tokenclasses.index(lp.AccidentalStyleSpecifier)
444        except ValueError:
445            pass
446        else:
447            if lx.Space in tokenclasses[i+1:]:
448                return
449        if lp.ContextName in tokenclasses:
450            return completiondata.lilypond_accidental_styles
451        return completiondata.lilypond_accidental_styles_contexts
452
453    def hide_omit(self):
454        r"""test for \omit and \hide"""
455        indices = []
456        for t in "\\omit", "\\hide":
457            try:
458                indices.append(self.tokens.index(t, -6))
459            except ValueError:
460                pass
461        if not indices:
462            return
463        self.backuntil(lx.Space, lp.DotPath)
464        i = max(indices)
465        tokens = self.tokens[i+1:]
466        tokenclasses = self.tokenclasses()[i+1:]
467        if lp.GrobName not in tokenclasses[:-1]:
468            if lp.ContextName in tokenclasses:
469                return completiondata.lilypond_grobs
470            return completiondata.lilypond_contexts_and_grobs
471
472
473    # Mapping from Parsers to the lists of functions to run.
474    tests = {
475        lp.ParseGlobal: (
476            markup_top,
477            repeat,
478            toplevel,
479        ),
480        lp.ParseBook: (
481            markup_top,
482            book,
483        ),
484        lp.ParseBookPart: (
485            markup_top,
486            bookpart,
487        ),
488        lp.ParseScore: (
489            score,
490        ),
491        lp.ParseMusic: (
492            markup_top,
493            tweak,
494            scheme_word,
495            key,
496            clef,
497            repeat,
498            accidental_style,
499            hide_omit,
500            revert,
501            general_music,
502        ),
503        lp.ParseNoteMode: (
504            markup_top,
505            tweak,
506            scheme_word,
507            key,
508            clef,
509            repeat,
510            accidental_style,
511            hide_omit,
512            revert,
513            general_music,
514        ),
515        lp.ParseChordMode: (
516            markup_top,
517            tweak,
518            scheme_word,
519            key,
520            clef,
521            repeat,
522            accidental_style,
523            hide_omit,
524            revert,
525            general_music,
526        ),
527        lp.ParseDrumMode: (
528            markup_top,
529            tweak,
530            scheme_word,
531            key,
532            clef,
533            repeat,
534            hide_omit,
535            revert,
536            general_music,
537        ),
538        lp.ParseFigureMode: (
539            markup_top,
540            tweak,
541            scheme_word,
542            key,
543            clef,
544            repeat,
545            accidental_style,
546            hide_omit,
547            revert,
548            general_music,
549        ),
550        lp.ParseMarkup: (
551            markup,
552        ),
553        lp.ParseHeader: (
554            markup_top,
555            header,
556        ),
557        lp.ParsePaper: (
558            paper,
559        ),
560        lp.ParseLayout: (
561            accidental_style,
562            hide_omit,
563            layout,
564        ),
565        lp.ParseMidi: (
566            midi,
567        ),
568        lp.ParseContext: (
569            engraver,
570            context_variable_set,
571            context,
572        ),
573        lp.ParseWith: (
574            markup_top,
575            engraver,
576            context_variable_set,
577            with_,
578        ),
579        lp.ParseTranslator: (
580            translator,
581        ),
582        lp.ExpectTranslatorId: (
583            translator,
584        ),
585        lp.ParseOverride: (
586            override,
587        ),
588        lp.ParseRevert: (
589            override,
590        ),
591        lp.ParseSet: (
592            set_unset,
593        ),
594        lp.ParseUnset: (
595            set_unset,
596        ),
597        lp.ParseTweak: (
598            tweak,
599        ),
600        lp.ParseTweakGrobProperty: (
601            tweak,
602        ),
603        lp.ParseString: (
604            engraver,
605            clef,
606            repeat,
607            midi_instrument,
608            include,
609            language,
610        ),
611        lp.ParseClef: (
612            clef,
613        ),
614        lp.ParseRepeat: (
615            repeat,
616        ),
617        scm.ParseScheme: (
618            override,
619            tweak,
620            markup_override,
621            scheme_other,
622        ),
623        scm.ParseString: (
624            music_glyph,
625            midi_instrument,
626            font_name,
627        ),
628        lp.ParseLyricMode: (
629            markup_top,
630            repeat,
631            lyricmode,
632        ),
633        lp.ParseAccidentalStyle: (
634            accidental_style,
635        ),
636        lp.ParseScriptAbbreviationOrFingering: (
637            accidental_style,
638        ),
639        lp.ParseHideOmit: (
640            hide_omit,
641        ),
642        lp.ParseGrobPropertyPath: (
643            revert,
644        ),
645    }
646