packages feed

hackport-0.8.0.0: cabal/doc/cabaldomain.py

# -*- coding: utf-8 -*-
'''
Sphinx domain for documenting all things cabal

The main reason to use this instead of adding object types to std domain
is the ability to generate nice 'Reference' page and also provide some meta
data for objects described with directives described here.

Most directives have at least following optional arguments

`:since: 1.24`
    version of Cabal in which feature was added.

`:deprecated: 1.24`
`:deprecated:`
    Feature was deprecated, and optionally since which version.

`:removed: 3.0`
    Feature was removed

`:synopsis: Short desc`
    Text used as short description on reference page.


Added directives

.. rst:directive:: .. cabal::cfg-section

   Describes a package.cabal section, such as library or executable.

   All following `pkg-field` directives will add section name
   to their fields name for disambiguating duplicates.

   You can reset the section disambiguation with `.. pkg-section:: None`.

.. rst::role:: pkg-section

   References section added by `.. pkg-section`

.. rst:directive:: .. cabal::pkg-field

   Describes a package.cabal field.

   Can have a :default: field. Will group on reference page under pkg-section
   if set and parent header otherwise.

.. rst::role:: pkg-field

   References field added by `.. pkg-field`, fields can be disambiguated
   with section name `:pkg-field:`section:field`.


.. rst:directive:: .. cabal:cfg-section::

   Same as `.. cabal::pkg-section` but does not produce any visible output
   currently unused.

.. rst:directive:: .. cabal:cfg-field::

   Describes a cabal.project field.

   Can have multiple arguments, if arguments start with '-' then it is treated
   as a cabal flag.

   Can have a :default: field. Will group on reference page under pkg-section
   if set and parent header otherwise.

.. rst::role:: cfg-field

   References field added by `.. cfg-field`.

.. rst::role:: cfg-flag

   References flag added by `.. cfg-field`.


All roles can be supplied with title as in standard sphinx references::

   :pkg-field:`Build dependencies<build-depends>`


To be done:

- Directives for describing executables, their subcommands and flags.

  These should act in a way similar to `.. std::option` directive, but with
  extra meta. And should also end up in reference.

  At least setup and 'new-build` subcommands should get special directives

- Improve rendering of flags in `.. cfg-field::` directive. It should be
  possible without copy-pasting code from sphinx.directives.ObjectDescription
  by examining result of ObjectDescription.run and inserting flags into
  desc_content node.

  Alternatively Or `.. flags::` sub-directive can be added which will be aware
  of parent `.. cfg-field` directive.

- With same ObjectDescription.run trick as above, render since and deprecated
  info same way as standard object fields, and use fancy rendering only on
  references page.

- Add 'since_version` config value to sphinx env and use it to decide if
  version meta info should be rendered on reference page and thus reduce some
  clutter.
  Can also be used to generate 'Whats new' reference page

'''


import re

from docutils import nodes
from docutils.parsers.rst import Directive, directives, roles

import pygments.lexer as lexer
import pygments.token as token

from distutils.version import StrictVersion

from sphinx import addnodes
from sphinx.directives import ObjectDescription
from sphinx.domains import ObjType, Domain, Index
from sphinx.domains.std import StandardDomain
from sphinx.locale import _
from sphinx.roles import XRefRole
from sphinx.util.docfields import Field, DocFieldTransformer
from sphinx.util.nodes import make_refnode

def parse_deprecated(txt):
    if txt is None:
        return True
    try:
        return StrictVersion(txt)
    except ValueError:
        return True

def parse_flag(env, sig, signode):
    import re
    names = []
    for i, flag in enumerate(sig.split(',')):
        flag = flag.strip()
        sep = '='
        parts = flag.split('=')
        if len(parts) == 1:
            sep=' '
            parts = flag.split()
        if len(parts) == 0: continue

        name = parts[0]
        names.append(name)
        sig = sep + ' '.join(parts[1:])
        sig = re.sub(r'<([-a-zA-Z ]+)>', r'⟨\1⟩', sig)
        if i > 0:
            signode += addnodes.desc_name(', ', ', ')
        signode += addnodes.desc_name(name, name)
        if len(sig) > 0:
            signode += addnodes.desc_addname(sig, sig)

    return names[0]


class Meta(object):
    '''
    Meta data associated with object
    '''
    def __init__(self,
                 since=None,
                 deprecated=None,
                 removed=None,
                 synopsis=None,
                 title=None,
                 section=None,
                 index=0):
        self.since = since
        self.deprecated = deprecated
        self.removed = removed
        self.synopsis = synopsis
        self.title = title
        self.section = section
        self.index = index


def find_section_title(parent):
    '''
    Find current section id and title if possible
    '''
    while parent is not None:
        if isinstance(parent, nodes.section):
            break
        parent = parent.parent

    if parent is None:
        return None

    section_id = parent['ids'][0]
    section_name = parent['names'][0]

    for kid in parent:
        if isinstance(kid, nodes.title):
            return kid.astext(), section_id

    print(section_name, section_id)
    return section_name, section_id


class CabalSection(Directive):
    """
    Marks section to which following objects belong, used to disambiguate
    references to fields and flags which can have similar names

    Does not generate any output besides anchor.
    """
    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True
    option_spec = {
        'name': lambda x: x,
        'deprecated': parse_deprecated,
        'removed': StrictVersion,
        'since' : StrictVersion,
        'synopsis' : lambda x:x,
    }
    section_key = 'cabal:pkg-section'
    target_prefix = 'pkg-section-'
    indextemplate = ''
    indextype = 'pair'

    def get_index_entry(self, name):
        return self.indextemplate % name

    def run(self):
        env = self.state.document.settings.env
        section = self.arguments[0].strip()

        if ':' in self.name:
            self.domain, self.objtype = self.name.split(':', 1)
        else:
            self.domain, self.objtype = '', self.name

        if section == 'None':
            env.ref_context.pop(self.section_key, None)
            return []

        env.ref_context[self.section_key] = section
        targetname = self.target_prefix + section
        node = nodes.target('', '', ids=[targetname])
        self.state.document.note_explicit_target(node)

        indexentry = self.get_index_entry(section)

        inode = addnodes.index(
            entries = [
                (self.indextype, indexentry, targetname, '', None)])

        # find title of parent section node
        title = find_section_title(self.state.parent)

        data_key = CabalDomain.types[self.objtype]

        # find how many sections in this document were added
        num = env.domaindata['cabal']['index-num'].get(env.docname, 0)
        env.domaindata['cabal']['index-num'][env.docname] = num + 1

        meta = Meta(since=self.options.get('since'),
                    deprecated=self.options.get('deprecated'),
                    removed=self.options.get('removed'),
                    synopsis=self.options.get('synopsis'),
                    index = num,
                    title = title)

        store = env.domaindata['cabal'][data_key]
        if not section in store:
            store[section] = env.docname, targetname, meta

        return [inode, node]


class CabalObject(ObjectDescription):
    option_spec = {
        'noindex'   : directives.flag,
        'deprecated': parse_deprecated,
        'removed'   : StrictVersion,
        'since'     : StrictVersion,
        'synopsis'  : lambda x:x
    }

    # node attribute marking which section field belongs to
    section_key = ''
    # template for index, it is passed a field name as argument
    # used by default deg_index_entry method
    indextemplate = ''

    def get_meta(self):
        '''
        Collect meta data for fields

        Reads optional arguments passed to directive and also
        tries to find current section title and adds it as section
        '''
        env = self.state.document.settings.env
        # find title of current section, will group references page by it
        num = env.domaindata['cabal']['index-num'].get(env.docname, 0)
        env.domaindata['cabal']['index-num'][env.docname] = num + 1

        title = find_section_title(self.state.parent)
        return Meta(since=self.options.get('since'),
                    deprecated=self.options.get('deprecated'),
                    removed=self.options.get('removed'),
                    title=title,
                    index = num,
                    synopsis=self.options.get('synopsis'))

    def get_env_key(self, env, name):
        '''
        Should return a key used to reference this field and key in domain
        data to store this object
        '''
        section = self.env.ref_context.get(self.section_key)
        store = CabalDomain.types[self.objtype]
        return (section, name), store

    def get_index_entry(self, env, name):
        '''
        Should return index entry and anchor

        By default uses indextemplate attribute to generate name and
        index entry by joining directive name, section and field name
        '''
        section = self.env.ref_context.get(self.section_key)

        if section is not None:
            parts = (self.objtype, section, name)
            indexentry = self.indextemplate % (section + ':' + name)
        else:
            parts = (self.objtype, name)
            indexentry = self.indextemplate % name

        targetname = '-'.join(parts)
        return indexentry, targetname


    def add_target_and_index(self, name, sig, signode):
        '''
        As in sphinx.directive.ObjectDescription

        By default adds 'pair' index as returned by get_index_entry and
        stores object data into domain data store as returned by get_env_data
        '''
        env = self.state.document.settings.env

        indexentry, targetname = self.get_index_entry(self, name)

        signode['ids'].append(targetname)
        self.state.document.note_explicit_target(signode)

        inode = addnodes.index(
            entries=[('pair', indexentry, targetname, '', None)])
        signode.insert(0, inode)

        key, store = self.get_env_key(env, name)
        env.domaindata['cabal'][store][key] = env.docname, targetname, self.cabal_meta

    def run(self):
        self.cabal_meta = self.get_meta()
        result = super(CabalObject, self).run()

        if self.cabal_meta.since is not None \
           or self.cabal_meta.deprecated is not None:

            #find content part of description
            for item in result:
                if isinstance(item, addnodes.desc):
                    desc = item
                    break
            else:
                return result

            for item in desc:
                if isinstance(item, addnodes.desc_content):
                    contents = item
                    break
            else:
                return result

            # find exsting field list and add to it
            # or create new one
            for item in contents:
                if isinstance(item, nodes.field_list):
                    field_list = item
                    break
            else:
                field_list = nodes.field_list('')
                contents.insert(0, field_list)


            if self.cabal_meta.since is not None:
                #docutils horror
                field = nodes.field('')
                field_name = nodes.field_name('Since', 'Since')
                since = 'Cabal ' + str(self.cabal_meta.since)
                field_body = nodes.field_body(since, nodes.paragraph(since, since))
                field += field_name
                field += field_body
                field_list.insert(0, field)

            if self.cabal_meta.deprecated is not None:
                field = nodes.field('')
                field_name = nodes.field_name('Deprecated', 'Deprecated')
                if isinstance(self.cabal_meta.deprecated, StrictVersion):
                    since = 'Cabal ' + str(self.cabal_meta.deprecated)
                else:
                    since = ''

                field_body = nodes.field_body(since, nodes.paragraph(since, since))
                field += field_name
                field += field_body
                field_list.insert(0, field)

            if self.cabal_meta.removed is not None:
                field = nodes.field('')
                field_name = nodes.field_name('Removed', 'Removed')
                if isinstance(self.cabal_meta.removed, StrictVersion):
                    since = 'Cabal ' + str(self.cabal_meta.removed)
                else:
                    since = ''

                field_body = nodes.field_body(since, nodes.paragraph(since, since))
                field += field_name
                field += field_body
                field_list.insert(0, field)
        return result

class CabalPackageSection(CabalObject):
    """
    Cabal section in package.cabal file
    """
    section_key = 'cabal:pkg-section'
    indextemplate = '%s; package.cabal section'

    def handle_signature(self, sig, signode):
        '''
        As in sphinx.directives.ObjectDescription

        By default make an object description from name and adding
        either deprecated or since as annotation.
        '''
        env = self.state.document.settings.env

        sig = sig.strip()
        parts = sig.split(' ',1)
        name = parts[0]
        signode += addnodes.desc_name(name, name)
        signode += addnodes.desc_addname(' ', ' ')
        if len(parts) > 1:
            rest = parts[1].strip()
            signode += addnodes.desc_annotation(rest, rest)

        return name

    def get_env_key(self, env, name):
        store = CabalDomain.types[self.objtype]
        return name, store

    def run(self):
        env = self.state.document.settings.env
        section = self.arguments[0].strip().split(' ',1)[0]
        if section == 'None':
            env.ref_context.pop('cabal:pkg-section', None)
            return []
        env.ref_context['cabal:pkg-section'] = section
        return super(CabalPackageSection, self).run()


class CabalField(CabalObject):
    '''
    Base for fields in *.cabal files
    '''
    option_spec = {
        'noindex'   : directives.flag,
        'deprecated': parse_deprecated,
        'removed'   : StrictVersion,
        'since'     : StrictVersion,
        'synopsis'  : lambda x:x
    }

    doc_field_types = [
        Field('default', label='Default value', names=['default'], has_arg=False)
    ]

    def handle_signature(self, sig, signode):
        '''
        As in sphinx.directives.ObjectDescription

        By default make an object description from name and adding
        either deprecated or since as annotation.
        '''
        env = self.state.document.settings.env

        sig = sig.strip()
        parts = sig.split(':',1)
        name = parts[0]
        signode += addnodes.desc_name(name, name)
        signode += addnodes.desc_addname(': ', ': ')

        if len(parts) > 1:
            rest = parts[1].strip()
            signode += addnodes.desc_annotation(rest, rest)

        return name

class CabalPackageField(CabalField):
    '''
    Describes section in package.cabal file
    '''
    section_key = 'cabal:pkg-section'
    indextemplate = '%s; package.cabal field'

class CabalFieldXRef(XRefRole):
    '''
    Cross ref node for all kinds of fields

    Gets section_key entry from context and stores it on node, so it can
    later be used by CabalDomain.resolve_xref to find target for reference to
    this
    '''
    section_key = 'cabal:pkg-section'
    def process_link(self, env, refnode, has_explicit_title, title, target):
        parts = target.split(':',1)
        if len(parts) == 2:
            section, target = parts
            section = section.strip()
            target = target.strip()
            refnode[self.section_key] = section
        else:
            refnode[self.section_key] = env.ref_context.get(self.section_key)

        return title, target

#
# Directives for config files.
#

class CabalPackageFieldXRef(CabalFieldXRef):
    '''
    Role referencing cabal.project section
    '''
    section_key = 'cabal:pkg-section'

class CabalConfigSection(CabalSection):
    """
    Marks section in package.cabal file
    """
    indextemplate = '%s; cabal.project section'
    section_key = 'cabal:cfg-section'
    target_prefix = 'cfg-section-'

class ConfigField(CabalField):
    section_key = 'cabal:cfg-section'
    indextemplate = '%s ; cabal project option'
    def handle_signature(self, sig, signode):
        sig = sig.strip()
        if sig.startswith('-'):
            name = parse_flag(self, sig, signode)
        else:
            name = super(ConfigField, self).handle_signature(sig, signode)

        return name

    def get_index_entry(self, env, name):
        if name.startswith('-'):
            section = self.env.ref_context.get(self.section_key)
            if section is not None:
                parts = ('cfg-flag', section, name)
                indexname = section + ':' + name
            else:
                parts = ('cfg-flag', name)
                indexname = name
            indexentry = name + '; cabal project option'
            targetname = '-'.join(parts)
            return indexentry, targetname
        else:
            return super(ConfigField,self).get_index_entry(env, name)

    def get_env_key(self, env, name):
        section = self.env.ref_context.get(self.section_key)
        if name.startswith('-'):
            return (section, name), 'cfg-flags'
        return (section, name), 'cfg-fields'

class CabalConfigFieldXRef(CabalFieldXRef):
    section_key = 'cabal:cfg-section'


#
# Cabal domain
#

class ConfigFieldIndex(Index):
    name = 'projectindex'
    localname = "Cabal reference"
    shortname = "Reference"

    class Entry(object):
        def __init__(self, typ, name, doc, anchor, meta):
            self.type = typ
            self.name = name
            self.doc = doc
            self.anchor = anchor
            self.meta = meta

    def _gather_data(self, obj_types):
        '''
        Gather objects and return [(title, [Entry])]
        '''
        def massage(typ, datum):
            name, (doc, anchor, meta) = datum
            return self.Entry(typ, name, doc, anchor, meta)

        fields = []
        for typ in obj_types:
            store = CabalDomain.types[typ]
            fields += [massage(typ, x)
                      for x in self.domain.data[store].items()]

        fields.sort(key=lambda x: (x.doc, x.meta.index))

        if len(fields) == 0:
            return []

        result = []
        current = []
        current_title = fields[0].meta.title
        for field in fields:
            if field.meta.title != current_title:
                result.append((current_title, current))
                current = []
                current_title = field.meta.title
            current.append(field)
        result.append((current_title, current))

        return result


    def generate(self, docnames=None):
        '''
        Try to group entries such that if entry has a section then put it
        into same group.

        Otherwise group it under same `title`.

        Try to keep in same order as it was defined.

        sort by (document, index)
        group on (document, doc_section)

        TODO: Check how to extract section numbers from (document,doc_section)
              and add it as annotation to titles
        '''

        # (title, section store, fields store)
        entries = [('cabal.project fields', 'cfg-section', 'cfg-field'),
                   ('cabal project flags', 'cfg-section', 'cfg-flag'),
                   ('package.cabal fields', 'pkg-section', 'pkg-field')]

        result = []
        for label, section_key, key in entries:

            data = self._gather_data([section_key, key])

            references = []
            for section, entries in data:
                if section is None:
                    elem_type = 0 # Normal entry
                else:
                    elem_type = 2 # sub_entry

                assert len(entries) != 0
                docname = entries[0].doc
                if section is not None:
                    section_title, section_anchor = section
                    references.append(
                        (section_title, 1, docname, section_anchor, '', '', ''))

                for entry in entries:
                    #todo deal with if
                    if isinstance(entry.name, tuple):
                        name = entry.name[1]
                    else:
                        name = entry.name

                    meta = entry.meta
                    extra = render_meta(meta)
                    descr = meta.synopsis if meta.synopsis is not None else ''
                    field = (name, elem_type, docname,
                             entry.anchor, extra, '', descr)
                    references.append(field)
            result.append((label, references))

        return result, False

def make_data_keys(typ, target, node):
    '''
    Returns a list of keys to search for targets of this type
    in domain data.

    Used for resolving references
    '''
    if typ == 'pkg-field':
        section = node.get('cabal:pkg-section')
        return [(section, target),
                (None, target)]
    elif typ in ('cfg-field', 'cfg-flag'):
        section = node.get('cabal:cfg-section')
        return [(section, target), (None, target)]
    else:
        return [target]


def render_deprecated(deprecated):
    if isinstance(deprecated, StrictVersion):
        return 'deprecated since: '+str(deprecated)
    else:
        return 'deprecated'

def render_removed(deprecated, removed):
    if isinstance(deprecated, StrictVersion):
        return 'removed in: ' + str(removed) + '; deprecated since: '+str(deprecated)
    else:
        return 'removed in: ' + str(removed)

def render_meta(meta):
    '''
    Render meta as short text

    Will render either deprecated or since info
    '''
    if meta.removed is not None:
        return render_removed(meta.deprecated, meta.removed)
    if meta.deprecated is not None:
        return render_deprecated(meta.deprecated)
    elif meta.since is not None:
        return 'since version: ' + str(meta.since)
    else:
        return ''

def render_meta_title(meta):
    '''
    Render meta as suitable to use in titles
    '''
    rendered = render_meta(meta)
    if rendered != '':
        return '(' + rendered + ')'
    return ''

def make_title(typ, key, meta):
    '''
    Render title of an object (section, field or flag)
    '''
    if typ == 'pkg-section':
        return "package.cabal " + key + " section " + render_meta_title(meta)

    elif typ == 'pkg-field':
        section, name = key
        if section is not None:
            base = "package.cabal " + section + " section " + name + ": field"
        else:
            base = "package.cabal " + name + " field"

        return base + render_meta_title(meta)

    elif typ == 'cfg-section':
        return "cabal.project " + key + " section " + render_meta_title(meta)

    elif typ == 'cfg-field':
        section, name = key
        return "cabal.project " + name + " field " + render_meta_title(meta)

    elif typ == 'cfg-flag':
        section, name = key
        return "cabal flag " + name + " " + render_meta_title(meta)

    else:
        raise ValueError("Unknown type: " + typ)

def make_full_name(typ, key, meta):
    '''
    Return an anchor name for object type
    '''
    if typ == 'pkg-section':
        return 'pkg-section-' + key

    elif typ == 'pkg-field':
        section, name = key
        if section is not None:
            return '-'.join(('pkg-field',section, name))
        else:
            return 'pkg-field-' + name

    elif typ == 'cfg-field':
        return 'cfg-field-' + key

    else:
        raise ValueError('Unknown object type: ' + typ)

class CabalDomain(Domain):
    '''
    Sphinx domain for cabal

    needs Domain.merge_doc for parallel building, just union all dicts
    '''
    name = 'cabal'
    label = 'Cabal'
    object_types = {
        'pkg-section': ObjType(_('pkg-section'), 'pkg-section'),
        'pkg-field'  : ObjType(_('pkg-field')  , 'pkg-field'  ),
        'cfg-section': ObjType(_('cfg-section'), 'cfg-section'),
        'cfg-field'  : ObjType(_('cfg-field')  , 'cfg-field' ),
    }
    directives = {
        'pkg-section': CabalPackageSection,
        'pkg-field'  : CabalPackageField,
        'cfg-section': CabalConfigSection,
        'cfg-field'  : ConfigField,
    }
    roles = {
        'pkg-section': XRefRole(warn_dangling=True),
        'pkg-field'  : CabalPackageFieldXRef(warn_dangling=True),
        'cfg-section': XRefRole(warn_dangling=True),
        'cfg-field'  : CabalConfigFieldXRef(warn_dangling=True),
        'cfg-flag'   : CabalConfigFieldXRef(warn_dangling=True),
    }
    initial_data = {
        'pkg-sections': {},
        'pkg-fields'  : {},
        'cfg-sections': {},
        'index-num'   : {}, #per document number of objects
                            # used to order references page
        'cfg-fields'  : {},
        'cfg-flags'   : {},
    }
    indices = [
        ConfigFieldIndex
    ]
    types = {
        'pkg-section': 'pkg-sections',
        'pkg-field'  : 'pkg-fields',
        'cfg-section': 'cfg-sections',
        'cfg-field'  : 'cfg-fields',
        'cfg-flag'   : 'cfg-flags',
    }
    def clear_doc(self, docname):
        for k in ['pkg-sections', 'pkg-fields', 'cfg-sections',
                  'cfg-fields', 'cfg-flags']:
            to_del = []
            for name, (fn, _, _) in self.data[k].items():
                if fn == docname:
                    to_del.append(name)
            for name in to_del:
                del self.data[k][name]
        try:
            del self.data['index-num'][docname]
        except KeyError:
            pass

    def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode):
        objtypes = self.objtypes_for_role(type)
        for typ, key in ((typ, key)
                         for typ in objtypes
                         for key in make_data_keys(typ, target, node)):
            try:
                data = env.domaindata['cabal'][self.types[typ]][key]
            except KeyError:
                continue
            doc, ref, meta = data
            title = make_title(typ, key, meta)
            return make_refnode(builder, fromdocname, doc, ref, contnode, title)

    def get_objects(self):
        '''
        Used for search functionality
        '''
        for typ in ['pkg-section', 'pkg-field',
                    'cfg-section', 'cfg-field', 'cfg-flag']:
            key = self.types[typ]
            for name, (fn, target, meta) in self.data[key].items():
                title = make_title(typ, name, meta)
                yield title, title, typ, fn, target, 0

class CabalLexer(lexer.RegexLexer):
    '''
    Basic cabal lexer, does not try to be smart
    '''
    name = 'Cabal'
    aliases = ['cabal']
    filenames = ['.cabal']
    flags = re.MULTILINE

    tokens = {
      'root' : [
          (r'^(\s*)(--.*)$', lexer.bygroups(token.Whitespace, token.Comment.Single)),
          # key: value
          (r'^(\s*)([\w\-_]+)(:)',
           lexer.bygroups(token.Whitespace, token.Keyword, token.Punctuation)),
          (r'^([\w\-_]+)', token.Keyword), # library, executable, flag etc.
          (r'[^\S\n]+', token.Text),
          (r'&&|\|\||==|<=|\^>=|>=|<|>', token.Operator),
          (r',|:|{|}', token.Punctuation),
          (r'.', token.Text)
      ],
    }

def setup(app):
    app.add_domain(CabalDomain)
    app.add_lexer('cabal', CabalLexer)