Repository: google/vimdoc

Issue:
[usage]How to add something to an exist section?
@dbarnett Hi, I am author of SpaceVim, I want to use vimdoc generate doc for SpaceVim-layers. in this PR, https://github.com/SpaceVim/SpaceVim/pull/127 , I define a Layers section in `autoload/SpaceVim/layers.vim`, and all the layers is in `autoload/SpaceVim/layers/`, for example `autoload/SpaceVim/layers/lang/php.vim` is `lang#php` layer. and the doc for this layer is in this vim script, But I want to append the doc of this layer to the end of section Layers, and make the doc looks like:
```help
LAYERS                                                       *SpaceVim-layers*

       SpaceVim support many kinds of layers, in each layer contains 
  some plugins and config. here is the list of layers supported by 
  SpaceVim.
  
  lang#php :
              this layer is for php development, and it provide auto 
         codo completion, and syntax check, and jump to the definition
         location.
      
         requirement:
              PHP 5.3+
              PCNTL Extension
              Msgpack 0.5.7+(for NeoVim) Extension or JSON(for Vim 7.4+) Extension
              Composer Project

```

Discussion:

Patch:
diff --git a/README.md b/README.md
index e322fcb..79fb40e 100644
--- a/README.md
+++ b/README.md
@@ -116,10 +116,13 @@ Available block directives include:
 - `@private` marks a function private.
 - `@section name[, id]` allows you to write a new section for the helpfile. The
   id will be a lowercased version of name if omitted.
+- `@parentsection id` defines the current section as a child of the given
+  section. Must be contained within a `@section` block.
 - `@subsection name` defines a subsection (heading) within a section block.
 - `@backmatter id` declares a block to be rendered at the end of the given
   section.
-- `@order ...` allows you to define the order of the sections.
+- `@order ...` allows you to define the order of the sections. Sections with a
+  `@parentsection` may not be included here.
 - `@dict name` (above blank lines) allows you to define a new dictionary.
 - `@dict dict.fn` (above a function) allows you to add a function to
   a dictionary.
diff --git a/vimdoc/block.py b/vimdoc/block.py
index 03bed17..b02113b 100644
--- a/vimdoc/block.py
+++ b/vimdoc/block.py
@@ -32,6 +32,9 @@ class Block(object):
     # name (of section)
     # type (constant, e.g. vimdoc.FUNCTION)
     # id (of section, in section or backmatter)
+    # parent_id (in section)
+    # children (in section)
+    # level (in section, tracks nesting level)
     # namespace (of function)
     # attribute (of function in dict)
     self.locals = {}
@@ -137,6 +140,12 @@ class Block(object):
     else:
       raise error.TypeConflict(ourtype, newtype)
 
+  def SetParentSection(self, parent_id):
+    """Sets the parent_id for blocks of type SECTION"""
+    if not (self.locals.get('type') == vimdoc.SECTION):
+      raise error.MisplacedParentSection(parent_id)
+    self.Local(parent_id=parent_id)
+
   def SetHeader(self, directive):
     """Sets the header handler."""
     if self.header:
diff --git a/vimdoc/docline.py b/vimdoc/docline.py
index 51b588e..c5c3251 100644
--- a/vimdoc/docline.py
+++ b/vimdoc/docline.py
@@ -180,8 +180,12 @@ class Public(BlockDirective):
 class Section(BlockDirective):
   REGEX = regex.section_args
 
+  def __init__(self, args):
+    super(Section, self).__init__(args)
+
   def Assign(self, name, ident):
     self.name = name.replace('\\,', ',').replace('\\\\', '\\')
+
     if ident is None:
       # If omitted, it's the name in lowercase, with spaces converted to dashes.
       ident = self.name.lower().replace(' ', '-')
@@ -192,6 +196,16 @@ class Section(BlockDirective):
     block.Local(name=self.name, id=self.id)
 
 
+class ParentSection(BlockDirective):
+  REGEX = regex.parent_section_args
+
+  def Assign(self, name):
+    self.name = name.lower()
+
+  def Update(self, block):
+    block.SetParentSection(self.name)
+
+
 class Setting(BlockDirective):
   REGEX = regex.one_arg
 
@@ -384,6 +398,7 @@ BLOCK_DIRECTIVES = {
     'function': Function,
     'library': Library,
     'order': Order,
+    'parentsection': ParentSection,
     'private': Private,
     'public': Public,
     'section': Section,
diff --git a/vimdoc/error.py b/vimdoc/error.py
index 127ad53..725b128 100644
--- a/vimdoc/error.py
+++ b/vimdoc/error.py
@@ -116,6 +116,20 @@ class NoSuchSection(BadStructure):
         'Section {} never defined.'.format(section))
 
 
+class MisplacedParentSection(BadStructure):
+  def __init__(self, section):
+    super(MisplacedParentSection, self).__init__(
+        'Parent section {} defined outside a @section block.'.format(section))
+
+
+class NoSuchParentSection(BadStructure):
+  def __init__(self, section, parent_id):
+    super(NoSuchParentSection, self).__init__(
+        ('Section {} has non-existent parent {}. '
+         'Try setting the id of the parent section explicitly.'
+        ).format(section, parent_id))
+
+
 class DuplicateSection(BadStructure):
   def __init__(self, section):
     super(DuplicateSection, self).__init__(
@@ -132,3 +146,9 @@ class NeglectedSections(BadStructure):
   def __init__(self, sections, order):
     super(NeglectedSections, self).__init__(
         'Sections {} not included in ordering {}.'.format(sections, order))
+
+
+class OrderedChildSections(BadStructure):
+  def __init__(self, section, order):
+    super(OrderedChildSections, self).__init__(
+        'Child section {} included in ordering {}.'.format(section, order))
diff --git a/vimdoc/module.py b/vimdoc/module.py
index 05b09d1..4a50821 100644
--- a/vimdoc/module.py
+++ b/vimdoc/module.py
@@ -149,19 +149,57 @@ class Module(object):
     for backmatter in self.backmatters:
       if backmatter not in self.sections:
         raise error.NoSuchSection(backmatter)
+
     # Use explicit order as partial ordering and merge with default section
     # ordering. All custom sections must be ordered explicitly.
     self.order = self._GetSectionOrder(self.order, self.sections)
 
+    # Child section collection
+    to_delete = []
+    for key in self.sections:
+      section = self.sections[key]
+      parent_id = section.locals.get('parent_id', None)
+      if parent_id:
+        if parent_id not in self.sections:
+          raise error.NoSuchParentSection(
+                  section.locals['name'], parent_id)
+        parent = self.sections[parent_id]
+        parent.locals.setdefault('children', []).append(section)
+        to_delete.append(key)
+
+    for key in to_delete:
+      self.sections.pop(key)
+
     known = set(self.sections)
     neglected = sorted(known.difference(self.order))
     if neglected:
       raise error.NeglectedSections(neglected, self.order)
-    # Sections are now in order.
+
+    # Reinsert top-level sections in the correct order, expanding the tree of
+    # child sections along the way so we have a linear list of sections to pass
+    # to the output functions.
+
+    # Helper function to recursively add children to self.sections.
+    # We add a 'level' variable to locals so that WriteTableOfContents can keep
+    # track of the nesting.
+    def _AddChildSections(section):
+      section.locals.setdefault('level', 0)
+      if 'children' in section.locals:
+        sort_key = lambda s: s.locals['name']
+        for child in sorted(section.locals['children'], key=sort_key):
+          child.locals['level'] = section.locals['level'] + 1
+          self.sections[child.locals['id']] = child
+          _AddChildSections(child)
+
+    # Insert sections according to the @order directive
     for key in self.order:
       if key in self.sections:
+        section = self.sections.pop(key)
+        if 'parent_id' in section.locals and section.locals['parent_id']:
+          raise error.OrderedChildSections(section.locals['id'], self.order)
         # Move to end.
-        self.sections[key] = self.sections.pop(key)
+        self.sections[key] = section
+        _AddChildSections(section)
 
   def Chunks(self):
     for ident, section in self.sections.items():
diff --git a/vimdoc/output.py b/vimdoc/output.py
index a472b6b..7f9b025 100644
--- a/vimdoc/output.py
+++ b/vimdoc/output.py
@@ -52,16 +52,33 @@ class Helpfile(object):
       self.WriteLine(right=tag)
     self.WriteLine()
 
+  # Helper function for WriteTableOfContents().
+  def _EnumerateIndices(self, sections):
+    """Keep track of section numbering for each level of the tree"""
+    count = [{'level': 0, 'index': 0}]
+    for block in sections:
+      assert 'id' in block.locals
+      assert 'name' in block.locals
+      level = block.locals['level']
+      while level < count[-1]['level']:
+        count.pop()
+      if level == count[-1]['level']:
+        count[-1]['index'] += 1
+      else:
+        count.append({'level': level, 'index': 1})
+      yield (count[-1]['index'], block)
+
   def WriteTableOfContents(self):
     """Writes the table of contents."""
     self.WriteRow()
     self.WriteLine('CONTENTS', right=self.Tag(self.Slug('contents')))
-    for i, block in enumerate(self.module.sections.values()):
-      assert 'id' in block.locals
-      assert 'name' in block.locals
-      line = '%d. %s' % (i + 1, block.locals['name'])
-      slug = self.Slug(block.locals['id'])
-      self.WriteLine(line, indent=1, right=self.Link(slug), fill='.')
+    # We need to keep track of section numbering on a per-level basis
+    for index, block in self._EnumerateIndices(self.module.sections.values()):
+      self.WriteLine(
+          '%d. %s' % (index, block.locals['name']),
+          indent=2 * block.locals['level'] + 1,
+          right=self.Link(self.Slug(block.locals['id'])),
+          fill='.')
     self.WriteLine()
 
   def WriteChunk(self, chunk):
diff --git a/vimdoc/regex.py b/vimdoc/regex.py
index 39c23dc..8e977cd 100644
--- a/vimdoc/regex.py
+++ b/vimdoc/regex.py
@@ -42,6 +42,10 @@ True
 >>> section_args.match('The Beginning, beg').groups()
 ('The Beginning', 'beg')
 
+>>> parent_section_args.match('123')
+>>> parent_section_args.match('foo').groups()
+('foo',)
+
 >>> backmatter_args.match('123')
 >>> backmatter_args.match('foo').groups()
 ('foo',)
@@ -211,7 +215,8 @@ section_args = re.compile(r"""
   # MATCH GROUP 1: The Name
   (
     # Non-commas or escaped commas or escaped escapes.
-    (?:[^\\,]|\\.)+
+    # Must not end with a space.
+    (?:[^\\,]|\\.)+\S
   )
   # Optional identifier
   (?:
@@ -222,6 +227,7 @@ section_args = re.compile(r"""
   )?
   $
 """, re.VERBOSE)
+parent_section_args = re.compile(r'([a-zA-Z_-][a-zA-Z0-9_-]*)')
 backmatter_args = re.compile(r'([a-zA-Z_-][a-zA-Z0-9_-]*)')
 dict_args = re.compile(r"""
   ^([a-zA-Z_][a-zA-Z0-9]*)(?:\.([a-zA-Z_][a-zA-Z0-9_]*))?$


Test patch:
diff --git a/tests/module_tests.py b/tests/module_tests.py
index 1226997..1e74e04 100644
--- a/tests/module_tests.py
+++ b/tests/module_tests.py
@@ -64,6 +64,64 @@ class TestVimModule(unittest.TestCase):
     main_module.Close()
     self.assertEqual([commands, about, intro], list(main_module.Chunks()))
 
+  def test_child_sections(self):
+    """Sections should be ordered after their parents."""
+    plugin = module.VimPlugin('myplugin')
+    main_module = module.Module('myplugin', plugin)
+    first = Block(vimdoc.SECTION)
+    first.Local(name='Section 1', id='first')
+    # Configure explicit order.
+    first.Global(order=['first', 'second', 'third'])
+    second = Block(vimdoc.SECTION)
+    second.Local(name='Section 2', id='second')
+    third = Block(vimdoc.SECTION)
+    third.Local(name='Section 3', id='third')
+    child11 = Block(vimdoc.SECTION)
+    child11.Local(name='child11', id='child11', parent_id='first')
+    child12 = Block(vimdoc.SECTION)
+    child12.Local(name='child12', id='child12', parent_id='first')
+    child21 = Block(vimdoc.SECTION)
+    child21.Local(name='child21', id='child21', parent_id='second')
+    # Merge in arbitrary order.
+    for m in [second, child12, third, child11, first, child21]:
+      main_module.Merge(m)
+    main_module.Close()
+    self.assertEqual(
+        [first, child11, child12, second, child21, third],
+        list(main_module.Chunks()))
+
+  def test_missing_parent(self):
+    """Parent sections should exist."""
+    plugin = module.VimPlugin('myplugin')
+    main_module = module.Module('myplugin', plugin)
+    first = Block(vimdoc.SECTION)
+    first.Local(name='Section 1', id='first')
+    second = Block(vimdoc.SECTION)
+    second.Local(name='Section 2', id='second', parent_id='missing')
+    main_module.Merge(first)
+    main_module.Merge(second)
+    with self.assertRaises(error.NoSuchParentSection) as cm:
+      main_module.Close()
+    expected = (
+        'Section Section 2 has non-existent parent missing. '
+        'Try setting the id of the parent section explicitly.')
+    self.assertEqual((expected,), cm.exception.args)
+
+  def test_ordered_child(self):
+    """Child sections should not be included in @order."""
+    plugin = module.VimPlugin('myplugin')
+    main_module = module.Module('myplugin', plugin)
+    first = Block(vimdoc.SECTION)
+    first.Local(name='Section 1', id='first')
+    second = Block(vimdoc.SECTION)
+    second.Local(name='Section 2', id='second', parent_id='first')
+    first.Global(order=['first', 'second'])
+    main_module.Merge(first)
+    main_module.Merge(second)
+    with self.assertRaises(error.OrderedChildSections) as cm:
+      main_module.Close()
+    self.assertEqual(("Child section second included in ordering ['first', 'second'].",), cm.exception.args)
+
   def test_partial_ordering(self):
     """Always respect explicit order and prefer built-in ordering.