# -*- coding: utf-8 -*-
"""
    sphinx.domains.ruby
    ~~~~~~~~~~~~~~~~~~~

    The Ruby domain.

    :copyright: Copyright 2010 by SHIBUKAWA Yoshiki
    :license: BSD, see LICENSE for details.
"""

import re

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

from sphinx import addnodes
from sphinx import version_info
from sphinx.roles import XRefRole
from sphinx.locale import _
from sphinx.domains import Domain, ObjType, Index
from sphinx.directives import ObjectDescription
from sphinx.util.nodes import make_refnode
from sphinx.util.docfields import Field, GroupedField, TypedField

# REs for Ruby signatures
rb_sig_re = re.compile(
    r'''^ ([\w.]*\.)?            # class name(s)
          (\$?\w+\??!?)  \s*     # thing name
          (?: \((.*)\)           # optional: arguments
           (?:\s* -> \s* (.*))?  #           return annotation
          )? $                   # and nothing more
          ''', re.VERBOSE)

rb_paramlist_re = re.compile(r'([\[\],])')  # split at '[', ']' and ','

separators = {
  'method':'#', 'attr_reader':'#', 'attr_writer':'#', 'attr_accessor':'#',
  'function':'.', 'classmethod':'.', 'class':'::', 'module':'::',
  'global':'', 'const':'::'}

rb_separator = re.compile(r"(?:\w+)?(?:::)?(?:\.)?(?:#)?")


def _iteritems(d):

    for k in d:
        yield k, d[k]


def ruby_rsplit(fullname):
    items = [item for item in rb_separator.findall(fullname)]
    return ''.join(items[:-2]), items[-1]


class RubyObject(ObjectDescription):
    """
    Description of a general Ruby object.
    """
    option_spec = {
        'noindex': directives.flag,
        'module': directives.unchanged,
    }

    doc_field_types = [
        TypedField('parameter', label=_('Parameters'),
                   names=('param', 'parameter', 'arg', 'argument'),
                   typerolename='obj', typenames=('paramtype', 'type')),
        TypedField('variable', label=_('Variables'), rolename='obj',
                   names=('var', 'ivar', 'cvar'),
                   typerolename='obj', typenames=('vartype',)),
        GroupedField('exceptions', label=_('Raises'), rolename='exc',
                     names=('raises', 'raise', 'exception', 'except'),
                     can_collapse=True),
        Field('returnvalue', label=_('Returns'), has_arg=False,
              names=('returns', 'return')),
        Field('returntype', label=_('Return type'), has_arg=False,
              names=('rtype',)),
    ]

    def get_signature_prefix(self, sig):
        """
        May return a prefix to put before the object name in the signature.
        """
        return ''

    def needs_arglist(self):
        """
        May return true if an empty argument list is to be generated even if
        the document contains none.
        """
        return False

    def handle_signature(self, sig, signode):
        """
        Transform a Ruby signature into RST nodes.
        Returns (fully qualified name of the thing, classname if any).

        If inside a class, the current class name is handled intelligently:
        * it is stripped from the displayed name if present
        * it is added to the full name (return value) if not present
        """
        m = rb_sig_re.match(sig)
        if m is None:
            raise ValueError
        name_prefix, name, arglist, retann = m.groups()
        if not name_prefix:
            name_prefix = ""
        # determine module and class name (if applicable), as well as full name
        modname = self.options.get(
            'module', self.env.temp_data.get('rb:module'))
        classname = self.env.temp_data.get('rb:class')
        if self.objtype == 'global':
            add_module = False
            modname = None
            classname = None
            fullname = name
        elif classname:
            add_module = False
            if name_prefix and name_prefix.startswith(classname):
                fullname = name_prefix + name
                # class name is given again in the signature
                name_prefix = name_prefix[len(classname):].lstrip('.')
            else:
                separator = separators[self.objtype]
                fullname = classname + separator + name_prefix + name
        else:
            add_module = True
            if name_prefix:
                classname = name_prefix.rstrip('.')
                fullname = name_prefix + name
            else:
                classname = ''
                fullname = name

        signode['module'] = modname
        signode['class'] = self.class_name = classname
        signode['fullname'] = fullname

        sig_prefix = self.get_signature_prefix(sig)
        if sig_prefix:
            signode += addnodes.desc_annotation(sig_prefix, sig_prefix)

        if name_prefix:
            signode += addnodes.desc_addname(name_prefix, name_prefix)
        # exceptions are a special case, since they are documented in the
        # 'exceptions' module.
        elif add_module and self.env.config.add_module_names:
            if self.objtype == 'global':
                nodetext = ''
                signode += addnodes.desc_addname(nodetext, nodetext)
            else:
                modname = self.options.get(
                    'module', self.env.temp_data.get('rb:module'))
                if modname and modname != 'exceptions':
                    nodetext = modname + separators[self.objtype]
                    signode += addnodes.desc_addname(nodetext, nodetext)

        signode += addnodes.desc_name(name, name)
        if not arglist:
            if self.needs_arglist():
                # for callables, add an empty parameter list
                signode += addnodes.desc_parameterlist()
            if retann:
                signode += addnodes.desc_returns(retann, retann)
            return fullname, name_prefix
        signode += addnodes.desc_parameterlist()

        stack = [signode[-1]]
        for token in rb_paramlist_re.split(arglist):
            if token == '[':
                opt = addnodes.desc_optional()
                stack[-1] += opt
                stack.append(opt)
            elif token == ']':
                try:
                    stack.pop()
                except IndexError:
                    raise ValueError
            elif not token or token == ',' or token.isspace():
                pass
            else:
                token = token.strip()
                stack[-1] += addnodes.desc_parameter(token, token)
        if len(stack) != 1:
            raise ValueError
        if retann:
            signode += addnodes.desc_returns(retann, retann)
        return fullname, name_prefix

    def get_index_text(self, modname, name):
        """
        Return the text for the index entry of the object.
        """
        raise NotImplementedError('must be implemented in subclasses')

    def _is_class_member(self):
        return self.objtype.endswith('method') or self.objtype.startswith('attr')

    def add_target_and_index(self, name_cls, sig, signode):
        if self.objtype == 'global':
            modname = ''
        else:
            modname = self.options.get(
                'module', self.env.temp_data.get('rb:module'))
        separator = separators[self.objtype]
        if self._is_class_member():
            if signode['class']:
                prefix = modname and modname + '::' or ''
            else:
                prefix = modname and modname + separator or ''
        else:
            prefix = modname and modname + separator or ''
        fullname = prefix + name_cls[0]
        # note target
        if fullname not in self.state.document.ids:
            signode['names'].append(fullname)
            signode['ids'].append(fullname)
            signode['first'] = (not self.names)
            self.state.document.note_explicit_target(signode)
            objects = self.env.domaindata['rb']['objects']
            if fullname in objects:
                self.env.warn(
                    self.env.docname,
                    'duplicate object description of %s, ' % fullname +
                    'other instance in ' +
                    self.env.doc2path(objects[fullname][0]),
                    self.lineno)
            objects[fullname] = (self.env.docname, self.objtype)

        indextext = self.get_index_text(modname, name_cls)
        if indextext:
            self.indexnode['entries'].append(
                _make_index('single', indextext, fullname, fullname))

    def before_content(self):
        # needed for automatic qualification of members (reset in subclasses)
        self.clsname_set = False

    def after_content(self):
        if self.clsname_set:
            self.env.temp_data['rb:class'] = None


class RubyModulelevel(RubyObject):
    """
    Description of an object on module level (functions, data).
    """

    def needs_arglist(self):
        return self.objtype == 'function'

    def get_index_text(self, modname, name_cls):
        if self.objtype == 'function':
            if not modname:
                return _('%s() (global function)') % name_cls[0]
            return _('%s() (module function in %s)') % (name_cls[0], modname)
        else:
            return ''


class RubyGloballevel(RubyObject):
    """
    Description of an object on module level (functions, data).
    """

    def get_index_text(self, modname, name_cls):
        if self.objtype == 'global':
            return _('%s (global variable)') % name_cls[0]
        else:
            return ''


class RubyEverywhere(RubyObject):
    """
    Description of a class member (methods, attributes).
    """

    def needs_arglist(self):
        return self.objtype == 'method'

    def get_index_text(self, modname, name_cls):
        name, cls = name_cls
        add_modules = self.env.config.add_module_names
        if self.objtype == 'method':
            try:
                clsname, methname = ruby_rsplit(name)
            except ValueError:
                if modname:
                    return _('%s() (in module %s)') % (name, modname)
                else:
                    return '%s()' % name
            if modname and add_modules:
                return _('%s() (%s::%s method)') % (methname, modname,
                                                          clsname)
            else:
                return _('%s() (%s method)') % (methname, clsname)
        else:
            return ''


class RubyClasslike(RubyObject):
    """
    Description of a class-like object (classes, exceptions).
    """

    def get_signature_prefix(self, sig):
        return self.objtype + ' '

    def get_index_text(self, modname, name_cls):
        if self.objtype == 'class':
            if not modname:
                return _('%s (class)') % name_cls[0]
            return _('%s (class in %s)') % (name_cls[0], modname)
        elif self.objtype == 'exception':
            return name_cls[0]
        else:
            return ''

    def before_content(self):
        RubyObject.before_content(self)
        if self.names:
            self.env.temp_data['rb:class'] = self.names[0][0]
            self.clsname_set = True


class RubyClassmember(RubyObject):
    """
    Description of a class member (methods, attributes).
    """

    def needs_arglist(self):
        return self.objtype.endswith('method')

    def get_signature_prefix(self, sig):
        if self.objtype == 'classmethod':
            return "classmethod %s." % self.class_name
        elif self.objtype == 'attr_reader':
            return "attribute [R] "
        elif self.objtype == 'attr_writer':
            return "attribute [W] "
        elif self.objtype == 'attr_accessor':
            return "attribute [R/W] "
        return ''

    def get_index_text(self, modname, name_cls):
        name, cls = name_cls
        add_modules = self.env.config.add_module_names
        if self.objtype == 'classmethod':
            try:
                clsname, methname = ruby_rsplit(name)
            except ValueError:
                return '%s()' % name
            if modname:
                return _('%s() (%s.%s class method)') % (methname, modname,
                                                         clsname)
            else:
                return _('%s() (%s class method)') % (methname, clsname)
        elif self.objtype.startswith('attr'):
            try:
                clsname, attrname = ruby_rsplit(name)
            except ValueError:
                return name
            if modname and add_modules:
                return _('%s (%s.%s attribute)') % (attrname, modname, clsname)
            else:
                return _('%s (%s attribute)') % (attrname, clsname)
        else:
            return ''

    def before_content(self):
        RubyObject.before_content(self)
        lastname = self.names and self.names[-1][1]
        if lastname and not self.env.temp_data.get('rb:class'):
            self.env.temp_data['rb:class'] = lastname.strip('.')
            self.clsname_set = True


class RubyModule(Directive):
    """
    Directive to mark description of a new module.
    """

    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = False
    option_spec = {
        'platform': lambda x: x,
        'synopsis': lambda x: x,
        'noindex': directives.flag,
        'deprecated': directives.flag,
    }

    def run(self):
        env = self.state.document.settings.env
        modname = self.arguments[0].strip()
        noindex = 'noindex' in self.options
        env.temp_data['rb:module'] = modname
        env.domaindata['rb']['modules'][modname] = \
            (env.docname, self.options.get('synopsis', ''),
             self.options.get('platform', ''), 'deprecated' in self.options)
        targetnode = nodes.target('', '', ids=['module-' + modname], ismod=True)
        self.state.document.note_explicit_target(targetnode)
        ret = [targetnode]
        # XXX this behavior of the module directive is a mess...
        if 'platform' in self.options:
            platform = self.options['platform']
            node = nodes.paragraph()
            node += nodes.emphasis('', _('Platforms: '))
            node += nodes.Text(platform, platform)
            ret.append(node)
        # the synopsis isn't printed; in fact, it is only used in the
        # modindex currently
        if not noindex:
            indextext = _('%s (module)') % modname
            inode = addnodes.index(entries=[_make_index(
                'single', indextext, 'module-' + modname, modname)])
            ret.append(inode)
        return ret

def _make_index(entrytype, entryname, target, ignored, key=None):
    # Sphinx 1.4 introduced backward incompatible changes, it now
    # requires 5 tuples.  Last one is categorization key.  See
    # http://www.sphinx-doc.org/en/stable/extdev/nodes.html#sphinx.addnodes.index
    if version_info >= (1, 4, 0, '', 0):
        return (entrytype, entryname, target, ignored, key)
    else:
        return (entrytype, entryname, target, ignored)

class RubyCurrentModule(Directive):
    """
    This directive is just to tell Sphinx that we're documenting
    stuff in module foo, but links to module foo won't lead here.
    """

    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = False
    option_spec = {}

    def run(self):
        env = self.state.document.settings.env
        modname = self.arguments[0].strip()
        if modname == 'None':
            env.temp_data['rb:module'] = None
        else:
            env.temp_data['rb:module'] = modname
        return []


class RubyXRefRole(XRefRole):
    def process_link(self, env, refnode, has_explicit_title, title, target):
        if not has_explicit_title:
            title = title.lstrip('.')   # only has a meaning for the target
            title = title.lstrip('#')
            if title.startswith("::"):
                title = title[2:]
            target = target.lstrip('~') # only has a meaning for the title
            # if the first character is a tilde, don't display the module/class
            # parts of the contents
            if title[0:1] == '~':
                m = re.search(r"(?:\.)?(?:#)?(?:::)?(.*)\Z", title)
                if m:
                    title = m.group(1)
        if not title.startswith("$"):
            refnode['rb:module'] = env.temp_data.get('rb:module')
            refnode['rb:class'] = env.temp_data.get('rb:class')
        # if the first character is a dot, search more specific namespaces first
        # else search builtins first
        if target[0:1] == '.':
            target = target[1:]
            refnode['refspecific'] = True
        return title, target


class RubyModuleIndex(Index):
    """
    Index subclass to provide the Ruby module index.
    """

    name = 'modindex'
    localname = _('Ruby Module Index')
    shortname = _('modules')

    def generate(self, docnames=None):
        content = {}
        # list of prefixes to ignore
        ignores = self.domain.env.config['modindex_common_prefix']
        ignores = sorted(ignores, key=len, reverse=True)
        # list of all modules, sorted by module name
        modules = sorted(_iteritems(self.domain.data['modules']),
                         key=lambda x: x[0].lower())
        # sort out collapsable modules
        prev_modname = ''
        num_toplevels = 0
        for modname, (docname, synopsis, platforms, deprecated) in modules:
            if docnames and docname not in docnames:
                continue

            for ignore in ignores:
                if modname.startswith(ignore):
                    modname = modname[len(ignore):]
                    stripped = ignore
                    break
            else:
                stripped = ''

            # we stripped the whole module name?
            if not modname:
                modname, stripped = stripped, ''

            entries = content.setdefault(modname[0].lower(), [])

            package = modname.split('::')[0]
            if package != modname:
                # it's a submodule
                if prev_modname == package:
                    # first submodule - make parent a group head
                    entries[-1][1] = 1
                elif not prev_modname.startswith(package):
                    # submodule without parent in list, add dummy entry
                    entries.append([stripped + package, 1, '', '', '', '', ''])
                subtype = 2
            else:
                num_toplevels += 1
                subtype = 0

            qualifier = deprecated and _('Deprecated') or ''
            entries.append([stripped + modname, subtype, docname,
                            'module-' + stripped + modname, platforms,
                            qualifier, synopsis])
            prev_modname = modname

        # apply heuristics when to collapse modindex at page load:
        # only collapse if number of toplevel modules is larger than
        # number of submodules
        collapse = len(modules) - num_toplevels < num_toplevels

        # sort by first letter
        content = sorted(_iteritems(content))

        return content, collapse


class RubyDomain(Domain):
    """Ruby language domain."""
    name = 'rb'
    label = 'Ruby'
    object_types = {
        'function':        ObjType(_('function'),         'func', 'obj'),
        'global':          ObjType(_('global variable'),  'global', 'obj'),
        'method':          ObjType(_('method'),           'meth', 'obj'),
        'class':           ObjType(_('class'),            'class', 'obj'),
        'exception':       ObjType(_('exception'),        'exc', 'obj'),
        'classmethod':     ObjType(_('class method'),     'meth', 'obj'),
        'attr_reader':     ObjType(_('attribute'),        'attr', 'obj'),
        'attr_writer':     ObjType(_('attribute'),        'attr', 'obj'),
        'attr_accessor':   ObjType(_('attribute'),        'attr', 'obj'),
        'const':           ObjType(_('const'),            'const', 'obj'),
        'module':          ObjType(_('module'),           'mod', 'obj'),
    }

    directives = {
        'function':        RubyModulelevel,
        'global':          RubyGloballevel,
        'method':          RubyEverywhere,
        'const':           RubyEverywhere,
        'class':           RubyClasslike,
        'exception':       RubyClasslike,
        'classmethod':     RubyClassmember,
        'attr_reader':     RubyClassmember,
        'attr_writer':     RubyClassmember,
        'attr_accessor':   RubyClassmember,
        'module':          RubyModule,
        'currentmodule':   RubyCurrentModule,
    }

    roles = {
        'func':  RubyXRefRole(fix_parens=False),
        'global':RubyXRefRole(),
        'class': RubyXRefRole(),
        'exc':   RubyXRefRole(),
        'meth':  RubyXRefRole(fix_parens=False),
        'attr':  RubyXRefRole(),
        'const': RubyXRefRole(),
        'mod':   RubyXRefRole(),
        'obj':   RubyXRefRole(),
    }
    initial_data = {
        'objects': {},  # fullname -> docname, objtype
        'modules': {},  # modname -> docname, synopsis, platform, deprecated
    }
    indices = [
        RubyModuleIndex,
    ]

    def clear_doc(self, docname):
        for fullname, (fn, _) in list(self.data['objects'].items()):
            if fn == docname:
                del self.data['objects'][fullname]
        for modname, (fn, _, _, _) in list(self.data['modules'].items()):
            if fn == docname:
                del self.data['modules'][modname]

    def find_obj(self, env, modname, classname, name, type, searchorder=0):
        """
        Find a Ruby object for "name", perhaps using the given module and/or
        classname.
        """
        # skip parens
        if name[-2:] == '()':
            name = name[:-2]

        if not name:
            return None, None

        objects = self.data['objects']

        newname = None
        if searchorder == 1:
            if modname and classname and \
                     modname + '::' + classname + '#' + name in objects:
                newname = modname + '::' + classname + '#' + name
            elif modname and classname and \
                     modname + '::' + classname + '.' + name in objects:
                newname = modname + '::' + classname + '.' + name
            elif modname and modname + '::' + name in objects:
                newname = modname + '::' + name
            elif modname and modname + '#' + name in objects:
                newname = modname + '#' + name
            elif modname and modname + '.' + name in objects:
                newname = modname + '.' + name
            elif classname and classname + '.' + name in objects:
                newname = classname + '.' + name
            elif classname and classname + '#' + name in objects:
                newname = classname + '#' + name
            elif name in objects:
                newname = name
        else:
            if name in objects:
                newname = name
            elif classname and classname + '.' + name in objects:
                newname = classname + '.' + name
            elif classname and classname + '#' + name in objects:
                newname = classname + '#' + name
            elif modname and modname + '::' + name in objects:
                newname = modname + '::' + name
            elif modname and modname + '#' + name in objects:
                newname = modname + '#' + name
            elif modname and modname + '.' + name in objects:
                newname = modname + '.' + name
            elif modname and classname and \
                     modname + '::' + classname + '#' + name in objects:
                newname = modname + '::' + classname + '#' + name
            elif modname and classname and \
                     modname + '::' + classname + '.' + name in objects:
                newname = modname + '::' + classname + '.' + name
            # special case: object methods
            elif type in ('func', 'meth') and '.' not in name and \
                 'object.' + name in objects:
                newname = 'object.' + name
        if newname is None:
            return None, None
        return newname, objects[newname]

    def resolve_xref(self, env, fromdocname, builder,
                     typ, target, node, contnode):
        if (typ == 'mod' or
            typ == 'obj' and target in self.data['modules']):
            docname, synopsis, platform, deprecated = \
                self.data['modules'].get(target, ('','','', ''))
            if not docname:
                return None
            else:
                title = '%s%s%s' % ((platform and '(%s) ' % platform),
                                    synopsis,
                                    (deprecated and ' (deprecated)' or ''))
                return make_refnode(builder, fromdocname, docname,
                                    'module-' + target, contnode, title)
        else:
            modname = node.get('rb:module')
            clsname = node.get('rb:class')
            searchorder = node.hasattr('refspecific') and 1 or 0
            name, obj = self.find_obj(env, modname, clsname,
                                      target, typ, searchorder)
            if not obj:
                return None
            else:
                return make_refnode(builder, fromdocname, obj[0], name,
                                    contnode, name)

    def get_objects(self):
        for modname, info in _iteritems(self.data['modules']):
            yield (modname, modname, 'module', info[0], 'module-' + modname, 0)
        for refname, (docname, type) in _iteritems(self.data['objects']):
            yield (refname, refname, type, docname, refname, 1)


def setup(app):
    app.add_domain(RubyDomain)
