Tweak RDoc to transclude a function into another function's documentation

Ruboids:

A recent post here, by Robert Dober, showed how to tweak RDoc to add a raw HTML directive, %html.

Below my sig is a monkey-patch, for Ruby 1.8's RDoc, which includes this tweak and adds another; the ability to insert a method's contents into another method's documentation. This allows us to do "literate programming". We can compete with the likes of Knuth and Sedgewick by inserting automatically tested source code into our verbiage.

To use the monkey patch, simply save it to a file, say rdoc_patch.rb and then add require 'rdoc_patch' inside your Rakefile. If your Rakefile is normal, it will run RDoc in-process, so that will get the patch.

I use the %html and %transclude tags like this:

  # %html <a name='assert_json' />
  # Example:

ยทยทยท

#
  # %transclude AssertJavaScriptTest#test_assert_json
  #
  def assert_json(...)
    ...
  end

The contents of test_assert_json will explain to us how to use assert_json. And the %html <a name=...> gives us a direct, external link to the method:

http://assertxpath.rubyforge.org/classes/AssertJavaScript.html#assert_json

I'm putting the code here because I don't think it's good enough as a direct patch to RDoc yet. The main reasons:

- the directives escape with %, where :x: is the RDoc standard.
- both these tweaks gleefully ignore the other documentation flavors
- future directives are no easier.

The ideal system would help users easily plug new directives in, without stitching them into each element of the Visitor Pattern.

--
  Phlip
  http://www.oreilly.com/catalog/9780596510657/
  "Test Driven Ajax (on Rails)"
  assert_xpath, assert_javascript, & assert_ajax

##################################################################
## eek eek ook ook patch to add directives to RDocage

require 'rdoc/rdoc'
require 'rdoc/markup/simple_markup'
require 'rdoc/markup/simple_markup/lines'
require 'rdoc/markup/simple_markup/fragments'
require 'rdoc/markup/simple_markup/to_html'

def find_method_contents(file_info, modool, method)
  file_info.each do |top|
    if mod = top.find_module_named(modool) and
      symbol = mod.find_local_symbol(method)
        return symbol.token_stream
    end
  end
  return nil
end

module SM

  class Line
    PURE_HTML = :PURE_HTML
    TRANSCLUDE = :TRANSCLUDE
  end

  class PureHTML < Fragment
    type_name Line::PURE_HTML
  end

  class Transclude < Fragment
    type_name Line::TRANSCLUDE
  end

  class ToHtml
    def accept_transclude(am, fragment)
      if found = find_method_contents(@context.context.parent.in_files, *fragment.txt.split('#'))
        @res << '<pre>'
        # to do: hilite syntax; make method name clickable
        found.each_with_index do |tok, index|
          next if 0 == index and tok.text =~ /^\#/
          next if 1 == index and tok.text == "\n"
          @res << tok.text
        end
        @res << '</pre>'
      else
        @res << fragment.txt
      end
    end

    def accept_pure_html(am, fragment)
      @res << fragment.txt
    end
  end

  class LineCollection
    def accept(am, visitor)
      visitor.start_accepting

      @fragments.each do |fragment|
        case fragment
        when Verbatim
          visitor.accept_verbatim(am, fragment)
        when PureHTML
           visitor.accept_pure_html(am, fragment)
        when Transclude
           visitor.accept_transclude(am, fragment)
        when Rule
          visitor.accept_rule(am, fragment)
        when ListStart
          visitor.accept_list_start(am, fragment)
        when ListEnd
          visitor.accept_list_end(am, fragment)
        when ListItem
          visitor.accept_list_item(am, fragment)
        when BlankLine
          visitor.accept_blank_line(am, fragment)
        when Heading
          visitor.accept_heading(am, fragment)
        when Paragraph
          visitor.accept_paragraph(am, fragment)
        end
      end

      visitor.end_accepting
    end

  end

  class SimpleMarkup
  private

    def assign_types_to_lines(margin = 0, level = 0)

      while line = @lines.next

        if /^\s*%html/ === line.text then
          line.text.sub!("%html","")
          line.stamp( Line::PURE_HTML, level )
          next
        end

        if /^\s*%transclude/ === line.text then
          line.text.sub!("%transclude","")
          line.stamp( Line::TRANSCLUDE, level )
          next
        end

        if line.isBlank?
          line.stamp(Line::BLANK, level)
          next
        end

        # if a line contains non-blanks before the margin, then it must belong
        # to an outer level

        text = line.text

        for i in 0...margin
          if text[i] != SPACE
            @lines.unget
            return
          end
        end

        active_line = text[margin..-1]

        # Rules (horizontal lines) look like
        #
        # --- (three or more hyphens)
        #
        # The more hyphens, the thicker the rule
        #

        if /^(---+)\s*$/ =~ active_line
          line.stamp(Line::RULE, level, $1.length-2)
          next
        end

        # Then look for list entries. First the ones that have to have
        # text following them (* xxx, - xxx, and dd. xxx)

        if SIMPLE_LIST_RE =~ active_line

          offset = margin + $1.length
          prefix = $2
          prefix_length = prefix.length

          flag = case prefix
                 when "*","-" then ListBase::BULLET
                 when /^\d/ then ListBase::NUMBER
                 when /^[A-Z]/ then ListBase::UPPERALPHA
                 when /^[a-z]/ then ListBase::LOWERALPHA
                 else raise "Invalid List Type: #{self.inspect}"
                 end

          line.stamp(Line::LIST, level+1, prefix, flag)
          text[margin, prefix_length] = " " * prefix_length
          assign_types_to_lines(offset, level + 1)
          next
        end

        if LABEL_LIST_RE =~ active_line
          offset = margin + $1.length
          prefix = $2
          prefix_length = prefix.length

          next if handled_labeled_list(line, level, margin, offset, prefix)
        end

        # Headings look like
        # = Main heading
        # == Second level
        # === Third
        #
        # Headings reset the level to 0

        if active_line[0] == ?= and active_line =~ /^(=+)\s*(.*)/
          prefix_length = $1.length
          prefix_length = 6 if prefix_length > 6
          line.stamp(Line::HEADING, 0, prefix_length)
          line.strip_leading(margin + prefix_length)
          next
        end

        # If the character's a space, then we have verbatim text,
        # otherwise

        if active_line[0] == SPACE
          line.strip_leading(margin) if margin > 0
          line.stamp(Line::VERBATIM, level)
        else
          line.stamp(Line::PARAGRAPH, level)
        end
      end
    end
  end
end

## eek eek ook ook patch to add directives to RDocage
##################################################################