1# Distributed under the OSI-approved BSD 3-Clause License.  See accompanying
2# file Copyright.txt or https://cmake.org/licensing for details.
3
4import os
5import re
6
7# Override much of pygments' CMakeLexer.
8# We need to parse CMake syntax definitions, not CMake code.
9
10# For hard test cases that use much of the syntax below, see
11# - module/FindPkgConfig.html (with "glib-2.0>=2.10 gtk+-2.0" and similar)
12# - module/ExternalProject.html (with http:// https:// git@; also has command options -E --build)
13# - manual/cmake-buildsystem.7.html (with nested $<..>; relative and absolute paths, "::")
14
15from pygments.lexers import CMakeLexer
16from pygments.token import Name, Operator, Punctuation, String, Text, Comment, Generic, Whitespace, Number
17from pygments.lexer import bygroups
18
19# Notes on regular expressions below:
20# - [\.\+-] are needed for string constants like gtk+-2.0
21# - Unix paths are recognized by '/'; support for Windows paths may be added if needed
22# - (\\.) allows for \-escapes (used in manual/cmake-language.7)
23# - $<..$<..$>..> nested occurrence in cmake-buildsystem
24# - Nested variable evaluations are only supported in a limited capacity. Only
25#   one level of nesting is supported and at most one nested variable can be present.
26
27CMakeLexer.tokens["root"] = [
28  (r'\b(\w+)([ \t]*)(\()', bygroups(Name.Function, Text, Name.Function), '#push'),     # fctn(
29  (r'\(', Name.Function, '#push'),
30  (r'\)', Name.Function, '#pop'),
31  (r'\[', Punctuation, '#push'),
32  (r'\]', Punctuation, '#pop'),
33  (r'[|;,.=*\-]', Punctuation),
34  (r'\\\\', Punctuation),                                   # used in commands/source_group
35  (r'[:]', Operator),
36  (r'[<>]=', Punctuation),                                  # used in FindPkgConfig.cmake
37  (r'\$<', Operator, '#push'),                              # $<...>
38  (r'<[^<|]+?>(\w*\.\.\.)?', Name.Variable),                # <expr>
39  (r'(\$\w*\{)([^\}\$]*)?(?:(\$\w*\{)([^\}]+?)(\}))?([^\}]*?)(\})',  # ${..} $ENV{..}, possibly nested
40    bygroups(Operator, Name.Tag, Operator, Name.Tag, Operator, Name.Tag, Operator)),
41  (r'([A-Z]+\{)(.+?)(\})', bygroups(Operator, Name.Tag, Operator)),  # DATA{ ...}
42  (r'[a-z]+(@|(://))((\\.)|[\w.+-:/\\])+', Name.Attribute),          # URL, git@, ...
43  (r'/\w[\w\.\+-/\\]*', Name.Attribute),                    # absolute path
44  (r'/', Name.Attribute),
45  (r'\w[\w\.\+-]*/[\w.+-/\\]*', Name.Attribute),            # relative path
46  (r'[A-Z]((\\.)|[\w.+-])*[a-z]((\\.)|[\w.+-])*', Name.Builtin), # initial A-Z, contains a-z
47  (r'@?[A-Z][A-Z0-9_]*', Name.Constant),
48  (r'[a-z_]((\\;)|(\\ )|[\w.+-])*', Name.Builtin),
49  (r'[0-9][0-9\.]*', Number),
50  (r'(?s)"(\\"|[^"])*"', String),                           # "string"
51  (r'\.\.\.', Name.Variable),
52  (r'<', Operator, '#push'),                                # <..|..> is different from <expr>
53  (r'>', Operator, '#pop'),
54  (r'\n', Whitespace),
55  (r'[ \t]+', Whitespace),
56  (r'#.*\n', Comment),
57  #  (r'[^<>\])\}\|$"# \t\n]+', Name.Exception),            # fallback, for debugging only
58]
59
60from docutils.parsers.rst import Directive, directives
61from docutils.transforms import Transform
62try:
63    from docutils.utils.error_reporting import SafeString, ErrorString
64except ImportError:
65    # error_reporting was not in utils before version 0.11:
66    from docutils.error_reporting import SafeString, ErrorString
67
68from docutils import io, nodes
69
70from sphinx.directives import ObjectDescription
71from sphinx.domains import Domain, ObjType
72from sphinx.roles import XRefRole
73from sphinx.util.nodes import make_refnode
74from sphinx import addnodes
75
76sphinx_before_1_4 = False
77sphinx_before_1_7_2 = False
78try:
79    from sphinx import version_info
80    if version_info < (1, 4):
81        sphinx_before_1_4 = True
82    if version_info < (1, 7, 2):
83        sphinx_before_1_7_2 = True
84except ImportError:
85    # The `sphinx.version_info` tuple was added in Sphinx v1.2:
86    sphinx_before_1_4 = True
87    sphinx_before_1_7_2 = True
88
89if sphinx_before_1_7_2:
90  # Monkey patch for sphinx generating invalid content for qcollectiongenerator
91  # https://github.com/sphinx-doc/sphinx/issues/1435
92  from sphinx.util.pycompat import htmlescape
93  from sphinx.builders.qthelp import QtHelpBuilder
94  old_build_keywords = QtHelpBuilder.build_keywords
95  def new_build_keywords(self, title, refs, subitems):
96    old_items = old_build_keywords(self, title, refs, subitems)
97    new_items = []
98    for item in old_items:
99      before, rest = item.split("ref=\"", 1)
100      ref, after = rest.split("\"")
101      if ("<" in ref and ">" in ref):
102        new_items.append(before + "ref=\"" + htmlescape(ref) + "\"" + after)
103      else:
104        new_items.append(item)
105    return new_items
106  QtHelpBuilder.build_keywords = new_build_keywords
107
108class CMakeModule(Directive):
109    required_arguments = 1
110    optional_arguments = 0
111    final_argument_whitespace = True
112    option_spec = {'encoding': directives.encoding}
113
114    def __init__(self, *args, **keys):
115        self.re_start = re.compile(r'^#\[(?P<eq>=*)\[\.rst:$')
116        Directive.__init__(self, *args, **keys)
117
118    def run(self):
119        settings = self.state.document.settings
120        if not settings.file_insertion_enabled:
121            raise self.warning('"%s" directive disabled.' % self.name)
122
123        env = self.state.document.settings.env
124        rel_path, path = env.relfn2path(self.arguments[0])
125        path = os.path.normpath(path)
126        encoding = self.options.get('encoding', settings.input_encoding)
127        e_handler = settings.input_encoding_error_handler
128        try:
129            settings.record_dependencies.add(path)
130            f = io.FileInput(source_path=path, encoding=encoding,
131                             error_handler=e_handler)
132        except UnicodeEncodeError as error:
133            raise self.severe('Problems with "%s" directive path:\n'
134                              'Cannot encode input file path "%s" '
135                              '(wrong locale?).' %
136                              (self.name, SafeString(path)))
137        except IOError as error:
138            raise self.severe('Problems with "%s" directive path:\n%s.' %
139                      (self.name, ErrorString(error)))
140        raw_lines = f.read().splitlines()
141        f.close()
142        rst = None
143        lines = []
144        for line in raw_lines:
145            if rst is not None and rst != '#':
146                # Bracket mode: check for end bracket
147                pos = line.find(rst)
148                if pos >= 0:
149                    if line[0] == '#':
150                        line = ''
151                    else:
152                        line = line[0:pos]
153                    rst = None
154            else:
155                # Line mode: check for .rst start (bracket or line)
156                m = self.re_start.match(line)
157                if m:
158                    rst = ']%s]' % m.group('eq')
159                    line = ''
160                elif line == '#.rst:':
161                    rst = '#'
162                    line = ''
163                elif rst == '#':
164                    if line == '#' or line[:2] == '# ':
165                        line = line[2:]
166                    else:
167                        rst = None
168                        line = ''
169                elif rst is None:
170                    line = ''
171            lines.append(line)
172        if rst is not None and rst != '#':
173            raise self.warning('"%s" found unclosed bracket "#[%s[.rst:" in %s' %
174                               (self.name, rst[1:-1], path))
175        self.state_machine.insert_input(lines, path)
176        return []
177
178class _cmake_index_entry:
179    def __init__(self, desc):
180        self.desc = desc
181
182    def __call__(self, title, targetid, main = 'main'):
183        # See https://github.com/sphinx-doc/sphinx/issues/2673
184        if sphinx_before_1_4:
185            return ('pair', u'%s ; %s' % (self.desc, title), targetid, main)
186        else:
187            return ('pair', u'%s ; %s' % (self.desc, title), targetid, main, None)
188
189_cmake_index_objs = {
190    'command':    _cmake_index_entry('command'),
191    'cpack_gen':  _cmake_index_entry('cpack generator'),
192    'envvar':     _cmake_index_entry('envvar'),
193    'generator':  _cmake_index_entry('generator'),
194    'genex':      _cmake_index_entry('genex'),
195    'guide':      _cmake_index_entry('guide'),
196    'manual':     _cmake_index_entry('manual'),
197    'module':     _cmake_index_entry('module'),
198    'policy':     _cmake_index_entry('policy'),
199    'prop_cache': _cmake_index_entry('cache property'),
200    'prop_dir':   _cmake_index_entry('directory property'),
201    'prop_gbl':   _cmake_index_entry('global property'),
202    'prop_inst':  _cmake_index_entry('installed file property'),
203    'prop_sf':    _cmake_index_entry('source file property'),
204    'prop_test':  _cmake_index_entry('test property'),
205    'prop_tgt':   _cmake_index_entry('target property'),
206    'variable':   _cmake_index_entry('variable'),
207    }
208
209def _cmake_object_inventory(env, document, line, objtype, targetid):
210    inv = env.domaindata['cmake']['objects']
211    if targetid in inv:
212        document.reporter.warning(
213            'CMake object "%s" also described in "%s".' %
214            (targetid, env.doc2path(inv[targetid][0])), line=line)
215    inv[targetid] = (env.docname, objtype)
216
217class CMakeTransform(Transform):
218
219    # Run this transform early since we insert nodes we want
220    # treated as if they were written in the documents.
221    default_priority = 210
222
223    def __init__(self, document, startnode):
224        Transform.__init__(self, document, startnode)
225        self.titles = {}
226
227    def parse_title(self, docname):
228        """Parse a document title as the first line starting in [A-Za-z0-9<$]
229           or fall back to the document basename if no such line exists.
230           The cmake --help-*-list commands also depend on this convention.
231           Return the title or False if the document file does not exist.
232        """
233        env = self.document.settings.env
234        title = self.titles.get(docname)
235        if title is None:
236            fname = os.path.join(env.srcdir, docname+'.rst')
237            try:
238                f = open(fname, 'r')
239            except IOError:
240                title = False
241            else:
242                for line in f:
243                    if len(line) > 0 and (line[0].isalnum() or line[0] == '<' or line[0] == '$'):
244                        title = line.rstrip()
245                        break
246                f.close()
247                if title is None:
248                    title = os.path.basename(docname)
249            self.titles[docname] = title
250        return title
251
252    def apply(self):
253        env = self.document.settings.env
254
255        # Treat some documents as cmake domain objects.
256        objtype, sep, tail = env.docname.partition('/')
257        make_index_entry = _cmake_index_objs.get(objtype)
258        if make_index_entry:
259            title = self.parse_title(env.docname)
260            # Insert the object link target.
261            if objtype == 'command':
262                targetname = title.lower()
263            elif objtype == 'guide' and not tail.endswith('/index'):
264                targetname = tail
265            else:
266                if objtype == 'genex':
267                    m = CMakeXRefRole._re_genex.match(title)
268                    if m:
269                        title = m.group(1)
270                targetname = title
271            targetid = '%s:%s' % (objtype, targetname)
272            targetnode = nodes.target('', '', ids=[targetid])
273            self.document.note_explicit_target(targetnode)
274            self.document.insert(0, targetnode)
275            # Insert the object index entry.
276            indexnode = addnodes.index()
277            indexnode['entries'] = [make_index_entry(title, targetid)]
278            self.document.insert(0, indexnode)
279            # Add to cmake domain object inventory
280            _cmake_object_inventory(env, self.document, 1, objtype, targetid)
281
282class CMakeObject(ObjectDescription):
283
284    def handle_signature(self, sig, signode):
285        # called from sphinx.directives.ObjectDescription.run()
286        signode += addnodes.desc_name(sig, sig)
287        if self.objtype == 'genex':
288            m = CMakeXRefRole._re_genex.match(sig)
289            if m:
290                sig = m.group(1)
291        return sig
292
293    def add_target_and_index(self, name, sig, signode):
294        if self.objtype == 'command':
295           targetname = name.lower()
296        else:
297           targetname = name
298        targetid = '%s:%s' % (self.objtype, targetname)
299        if targetid not in self.state.document.ids:
300            signode['names'].append(targetid)
301            signode['ids'].append(targetid)
302            signode['first'] = (not self.names)
303            self.state.document.note_explicit_target(signode)
304            _cmake_object_inventory(self.env, self.state.document,
305                                    self.lineno, self.objtype, targetid)
306
307        make_index_entry = _cmake_index_objs.get(self.objtype)
308        if make_index_entry:
309            self.indexnode['entries'].append(make_index_entry(name, targetid))
310
311class CMakeXRefRole(XRefRole):
312
313    # See sphinx.util.nodes.explicit_title_re; \x00 escapes '<'.
314    _re = re.compile(r'^(.+?)(\s*)(?<!\x00)<(.*?)>$', re.DOTALL)
315    _re_sub = re.compile(r'^([^()\s]+)\s*\(([^()]*)\)$', re.DOTALL)
316    _re_genex = re.compile(r'^\$<([^<>:]+)(:[^<>]+)?>$', re.DOTALL)
317    _re_guide = re.compile(r'^([^<>/]+)/([^<>]*)$', re.DOTALL)
318
319    def __call__(self, typ, rawtext, text, *args, **keys):
320        # Translate CMake command cross-references of the form:
321        #  `command_name(SUB_COMMAND)`
322        # to have an explicit target:
323        #  `command_name(SUB_COMMAND) <command_name>`
324        if typ == 'cmake:command':
325            m = CMakeXRefRole._re_sub.match(text)
326            if m:
327                text = '%s <%s>' % (text, m.group(1))
328        elif typ == 'cmake:genex':
329            m = CMakeXRefRole._re_genex.match(text)
330            if m:
331                text = '%s <%s>' % (text, m.group(1))
332        elif typ == 'cmake:guide':
333            m = CMakeXRefRole._re_guide.match(text)
334            if m:
335                text = '%s <%s>' % (m.group(2), text)
336        # CMake cross-reference targets frequently contain '<' so escape
337        # any explicit `<target>` with '<' not preceded by whitespace.
338        while True:
339            m = CMakeXRefRole._re.match(text)
340            if m and len(m.group(2)) == 0:
341                text = '%s\x00<%s>' % (m.group(1), m.group(3))
342            else:
343                break
344        return XRefRole.__call__(self, typ, rawtext, text, *args, **keys)
345
346    # We cannot insert index nodes using the result_nodes method
347    # because CMakeXRefRole is processed before substitution_reference
348    # nodes are evaluated so target nodes (with 'ids' fields) would be
349    # duplicated in each evaluated substitution replacement.  The
350    # docutils substitution transform does not allow this.  Instead we
351    # use our own CMakeXRefTransform below to add index entries after
352    # substitutions are completed.
353    #
354    # def result_nodes(self, document, env, node, is_ref):
355    #     pass
356
357class CMakeXRefTransform(Transform):
358
359    # Run this transform early since we insert nodes we want
360    # treated as if they were written in the documents, but
361    # after the sphinx (210) and docutils (220) substitutions.
362    default_priority = 221
363
364    def apply(self):
365        env = self.document.settings.env
366
367        # Find CMake cross-reference nodes and add index and target
368        # nodes for them.
369        for ref in self.document.traverse(addnodes.pending_xref):
370            if not ref['refdomain'] == 'cmake':
371                continue
372
373            objtype = ref['reftype']
374            make_index_entry = _cmake_index_objs.get(objtype)
375            if not make_index_entry:
376                continue
377
378            objname = ref['reftarget']
379            if objtype == 'guide' and CMakeXRefRole._re_guide.match(objname):
380                # Do not index cross-references to guide sections.
381                continue
382
383            targetnum = env.new_serialno('index-%s:%s' % (objtype, objname))
384
385            targetid = 'index-%s-%s:%s' % (targetnum, objtype, objname)
386            targetnode = nodes.target('', '', ids=[targetid])
387            self.document.note_explicit_target(targetnode)
388
389            indexnode = addnodes.index()
390            indexnode['entries'] = [make_index_entry(objname, targetid, '')]
391            ref.replace_self([indexnode, targetnode, ref])
392
393class CMakeDomain(Domain):
394    """CMake domain."""
395    name = 'cmake'
396    label = 'CMake'
397    object_types = {
398        'command':    ObjType('command',    'command'),
399        'cpack_gen':  ObjType('cpack_gen',  'cpack_gen'),
400        'envvar':     ObjType('envvar',     'envvar'),
401        'generator':  ObjType('generator',  'generator'),
402        'genex':      ObjType('genex',      'genex'),
403        'guide':      ObjType('guide',      'guide'),
404        'variable':   ObjType('variable',   'variable'),
405        'module':     ObjType('module',     'module'),
406        'policy':     ObjType('policy',     'policy'),
407        'prop_cache': ObjType('prop_cache', 'prop_cache'),
408        'prop_dir':   ObjType('prop_dir',   'prop_dir'),
409        'prop_gbl':   ObjType('prop_gbl',   'prop_gbl'),
410        'prop_inst':  ObjType('prop_inst',  'prop_inst'),
411        'prop_sf':    ObjType('prop_sf',    'prop_sf'),
412        'prop_test':  ObjType('prop_test',  'prop_test'),
413        'prop_tgt':   ObjType('prop_tgt',   'prop_tgt'),
414        'manual':     ObjType('manual',     'manual'),
415    }
416    directives = {
417        'command':    CMakeObject,
418        'envvar':     CMakeObject,
419        'genex':      CMakeObject,
420        'variable':   CMakeObject,
421        # Other object types cannot be created except by the CMakeTransform
422        # 'generator':  CMakeObject,
423        # 'module':     CMakeObject,
424        # 'policy':     CMakeObject,
425        # 'prop_cache': CMakeObject,
426        # 'prop_dir':   CMakeObject,
427        # 'prop_gbl':   CMakeObject,
428        # 'prop_inst':  CMakeObject,
429        # 'prop_sf':    CMakeObject,
430        # 'prop_test':  CMakeObject,
431        # 'prop_tgt':   CMakeObject,
432        # 'manual':     CMakeObject,
433    }
434    roles = {
435        'command':    CMakeXRefRole(fix_parens = True, lowercase = True),
436        'cpack_gen':  CMakeXRefRole(),
437        'envvar':     CMakeXRefRole(),
438        'generator':  CMakeXRefRole(),
439        'genex':      CMakeXRefRole(),
440        'guide':      CMakeXRefRole(),
441        'variable':   CMakeXRefRole(),
442        'module':     CMakeXRefRole(),
443        'policy':     CMakeXRefRole(),
444        'prop_cache': CMakeXRefRole(),
445        'prop_dir':   CMakeXRefRole(),
446        'prop_gbl':   CMakeXRefRole(),
447        'prop_inst':  CMakeXRefRole(),
448        'prop_sf':    CMakeXRefRole(),
449        'prop_test':  CMakeXRefRole(),
450        'prop_tgt':   CMakeXRefRole(),
451        'manual':     CMakeXRefRole(),
452    }
453    initial_data = {
454        'objects': {},  # fullname -> docname, objtype
455    }
456
457    def clear_doc(self, docname):
458        to_clear = set()
459        for fullname, (fn, _) in self.data['objects'].items():
460            if fn == docname:
461                to_clear.add(fullname)
462        for fullname in to_clear:
463            del self.data['objects'][fullname]
464
465    def resolve_xref(self, env, fromdocname, builder,
466                     typ, target, node, contnode):
467        targetid = '%s:%s' % (typ, target)
468        obj = self.data['objects'].get(targetid)
469        if obj is None:
470            # TODO: warn somehow?
471            return None
472        return make_refnode(builder, fromdocname, obj[0], targetid,
473                            contnode, target)
474
475    def get_objects(self):
476        for refname, (docname, type) in self.data['objects'].items():
477            yield (refname, refname, type, docname, refname, 1)
478
479def setup(app):
480    app.add_directive('cmake-module', CMakeModule)
481    app.add_transform(CMakeTransform)
482    app.add_transform(CMakeXRefTransform)
483    app.add_domain(CMakeDomain)
484