about summary refs log tree commit diff
path: root/docs/tool/Modules/NaturalDocs/Parser
diff options
context:
space:
mode:
Diffstat (limited to 'docs/tool/Modules/NaturalDocs/Parser')
-rw-r--r--docs/tool/Modules/NaturalDocs/Parser/JavaDoc.pm464
-rw-r--r--docs/tool/Modules/NaturalDocs/Parser/Native.pm1060
-rw-r--r--docs/tool/Modules/NaturalDocs/Parser/ParsedTopic.pm253
3 files changed, 1777 insertions, 0 deletions
diff --git a/docs/tool/Modules/NaturalDocs/Parser/JavaDoc.pm b/docs/tool/Modules/NaturalDocs/Parser/JavaDoc.pm
new file mode 100644
index 00000000..860b0c5f
--- /dev/null
+++ b/docs/tool/Modules/NaturalDocs/Parser/JavaDoc.pm
@@ -0,0 +1,464 @@
+###############################################################################
+#
+#   Package: NaturalDocs::Parser::JavaDoc
+#
+###############################################################################
+#
+#   A package for translating JavaDoc topics into Natural Docs.
+#
+#   Supported tags:
+#
+#       - @param
+#       - @author
+#       - @deprecated
+#       - @code, @literal (doesn't change font)
+#       - @exception, @throws (doesn't link to class)
+#       - @link, @linkplain (doesn't change font)
+#       - @return, @returns
+#       - @see
+#       - @since
+#       - @value (shown as link instead of replacement)
+#       - @version
+#
+#   Stripped tags:
+#
+#       - @inheritDoc
+#       - @serial, @serialField, @serialData
+#       - All other block level tags.
+#
+#   Unsupported tags:
+#
+#       These will appear literally in the output because I cannot handle them easily.
+#
+#       - @docRoot
+#       - Any other tags not mentioned
+#
+#   Supported HTML:
+#
+#       - p
+#       - b, i, u
+#       - pre
+#       - a href
+#       - ol, ul, li (ol gets converted to ul)
+#       - gt, lt, amp, quot, nbsp entities
+#
+#   Stripped HTML:
+#
+#       - code
+#       - HTML comments
+#
+#   Unsupported HTML:
+#
+#       These will appear literally in the output because I cannot handle them easily.
+#
+#       - Any tags with additional properties other than a href.  (ex. <p class=Something>)
+#       - Any other tags not mentioned
+#
+#   Reference:
+#
+#       http://java.sun.com/j2se/1.5.0/docs/tooldocs/windows/javadoc.html
+#
+###############################################################################
+
+# This file is part of Natural Docs, which is Copyright (C) 2003-2008 Greg Valure
+# Natural Docs is licensed under the GPL
+
+use strict;
+use integer;
+
+package NaturalDocs::Parser::JavaDoc;
+
+
+#
+#   hash: blockTags
+#   An existence hash of the all-lowercase JavaDoc block tags, not including the @.
+#
+my %blockTags = ( 'param' => 1, 'author' => 1, 'deprecated' => 1, 'exception' => 1, 'return' => 1, 'see' => 1,
+                             'serial' => 1, 'serialfield' => 1, 'serialdata' => 1, 'since' => 1, 'throws' => 1, 'version' => 1,
+                             'returns' => 1 );
+
+#
+#   hash: inlineTags
+#   An existence hash of the all-lowercase JavaDoc inline tags, not including the @.
+#
+my %inlineTags = ( 'inheritdoc' => 1, 'docroot' => 1, 'code' => 1, 'literal' => 1, 'link' => 1, 'linkplain' => 1, 'value' => 1 );
+
+
+##
+#   Examines the comment and returns whether it is *definitely* JavaDoc content, i.e. is owned by this package.
+#
+#   Parameters:
+#
+#       commentLines - An arrayref of the comment lines.  Must have been run through <NaturalDocs::Parser->CleanComment()>.
+#       isJavaDoc - Whether the comment is JavaDoc styled.  This doesn't necessarily mean it has JavaDoc content.
+#
+#   Returns:
+#
+#       Whether the comment is *definitely* JavaDoc content.
+#
+sub IsMine #(string[] commentLines, bool isJavaDoc)
+    {
+    my ($self, $commentLines, $isJavaDoc) = @_;
+
+    if (!$isJavaDoc)
+        {  return undef;  };
+
+    for (my $line = 0; $line < scalar @$commentLines; $line++)
+        {
+        if ($commentLines->[$line] =~ /^ *@([a-z]+) /i && exists $blockTags{$1} ||
+            $commentLines->[$line] =~ /\{@([a-z]+) /i && exists $inlineTags{$1})
+            {
+            return 1;
+            };
+        };
+
+    return 0;
+    };
+
+
+##
+#   Parses the JavaDoc-syntax comment and adds it to the parsed topic list.
+#
+#   Parameters:
+#
+#       commentLines - An arrayref of the comment lines.  Must have been run through <NaturalDocs::Parser->CleanComment()>.
+#                               *The original memory will be changed.*
+#       isJavaDoc - Whether the comment is JavaDoc styled.  This doesn't necessarily mean it has JavaDoc content.
+#       lineNumber - The line number of the first of the comment lines.
+#       parsedTopics - A reference to the array where any new <NaturalDocs::Parser::ParsedTopics> should be placed.
+#
+#   Returns:
+#
+#       The number of parsed topics added to the array, which in this case will always be one.
+#
+sub ParseComment #(string[] commentLines, bool isJavaDoc, int lineNumber, ParsedTopics[]* parsedTopics)
+    {
+    my ($self, $commentLines, $isJavaDoc, $lineNumber, $parsedTopics) = @_;
+
+
+    # Stage one: Before block level tags.
+
+    my $i = 0;
+    my $output;
+    my $unformattedText;
+    my $inCode;
+    my $sharedCodeIndent;
+
+    while ($i < scalar @$commentLines &&
+              !($commentLines->[$i] =~ /^ *@([a-z]+) /i && exists $blockTags{$1}) )
+        {
+        my $line = $self->ConvertAmpChars($commentLines->[$i]);
+        my @tokens = split(/(&lt;\/?pre&gt;)/, $line);
+
+        foreach my $token (@tokens)
+            {
+            if ($token =~ /^&lt;pre&gt;$/i)
+                {
+                if (!$inCode && $unformattedText)
+                    {
+                    $output .= '<p>' . $self->FormatText($unformattedText, 1) . '</p>';
+                    };
+
+                $inCode = 1;
+                $unformattedText = undef;
+                }
+            elsif ($token =~ /^&lt;\/pre&gt;$/i)
+                {
+                if ($inCode && $unformattedText)
+                    {
+                    $unformattedText =~ s/^ {$sharedCodeIndent}//mg;
+                    $unformattedText =~ s/\n{3,}/\n\n/g;
+                    $unformattedText =~ s/\n+$//;
+                    $output .= '<code>' . $unformattedText . '</code>';
+
+                    $sharedCodeIndent = undef;
+                    };
+
+                $inCode = 0;
+                $unformattedText = undef;
+                }
+            elsif (length($token))
+                {
+                if (!$inCode)
+                    {
+                    $token =~ s/^ +//;
+                    if ($unformattedText)
+                        {  $unformattedText .= ' ';  };
+                    }
+                else
+                    {
+                    $token =~ /^( *)/;
+                    my $indent = length($1);
+
+                    if (!defined $sharedCodeIndent || $indent < $sharedCodeIndent)
+                        {  $sharedCodeIndent = $indent;  };
+                    };
+
+                $unformattedText .= $token;
+                };
+            };
+
+        if ($inCode && $unformattedText)
+            {  $unformattedText .= "\n";  };
+
+        $i++;
+        };
+
+    if ($unformattedText)
+        {
+        if ($inCode)
+            {
+            $unformattedText =~ s/^ {$sharedCodeIndent}//mg;
+            $unformattedText =~ s/\n{3,}/\n\n/g;
+            $unformattedText =~ s/\n+$//;
+            $output .= '<code>' . $unformattedText . '</code>';
+            }
+        else
+            {  $output .= '<p>' . $self->FormatText($unformattedText, 1) . '</p>';  };
+
+        $unformattedText = undef;
+        };
+
+
+    # Stage two: Block level tags.
+
+    my ($keyword, $value, $unformattedTextPtr, $unformattedTextCloser);
+    my ($params, $authors, $deprecation, $throws, $returns, $seeAlso, $since, $version);
+
+
+    while ($i < scalar @$commentLines)
+        {
+        my $line = $self->ConvertAmpChars($commentLines->[$i]);
+        $line =~ s/^ +//;
+
+        if ($line =~ /^@([a-z]+) ?(.*)$/i)
+            {
+            ($keyword, $value) = (lc($1), $2);
+
+            # Process the previous one, if any.
+            if ($unformattedText)
+                {
+                $$unformattedTextPtr .= $self->FormatText($unformattedText) . $unformattedTextCloser;
+                $unformattedText = undef;
+                };
+
+            if ($keyword eq 'param')
+                {
+                $value =~ /^([a-z0-9_]+) *(.*)$/i;
+
+                $params .= '<de>' . $1 . '</de><dd>';
+                $unformattedText = $2;
+
+                $unformattedTextPtr = \$params;
+                $unformattedTextCloser = '</dd>';
+                }
+            elsif ($keyword eq 'exception' || $keyword eq 'throws')
+                {
+                $value =~ /^([a-z0-9_]+) *(.*)$/i;
+
+                $throws .= '<de>' . $1 . '</de><dd>';
+                $unformattedText = $2;
+
+                $unformattedTextPtr = \$throws;
+                $unformattedTextCloser = '</dd>';
+                }
+            elsif ($keyword eq 'return' || $keyword eq 'returns')
+                {
+                if ($returns)
+                    {  $returns .= ' ';  };
+
+                $unformattedText = $value;
+                $unformattedTextPtr = \$returns;
+                $unformattedTextCloser = undef;
+                }
+            elsif ($keyword eq 'author')
+                {
+                if ($authors)
+                    {  $authors .= ', ';  };
+
+                $unformattedText = $value;
+                $unformattedTextPtr = \$authors;
+                $unformattedTextCloser = undef;
+                }
+            elsif ($keyword eq 'deprecated')
+                {
+                if ($deprecation)
+                    {  $deprecation .= ' ';  };
+
+                $unformattedText = $value;
+                $unformattedTextPtr = \$deprecation;
+                $unformattedTextCloser = undef;
+                }
+            elsif ($keyword eq 'since')
+                {
+                if ($since)
+                    {  $since .= ', ';  };
+
+                $unformattedText = $value;
+                $unformattedTextPtr = \$since;
+                $unformattedTextCloser = undef;
+                }
+            elsif ($keyword eq 'version')
+                {
+                if ($version)
+                    {  $version .= ', ';  };
+
+                $unformattedText = $value;
+                $unformattedTextPtr = \$version;
+                $unformattedTextCloser = undef;
+                }
+            elsif ($keyword eq 'see')
+                {
+                if ($seeAlso)
+                    {  $seeAlso .= ', ';  };
+
+                $unformattedText = undef;
+
+                if ($value =~ /^&(?:quot|lt);/i)
+                    {  $seeAlso .= $self->FormatText($value);  }
+                else
+                    {  $seeAlso .= $self->ConvertLink($value);  };
+                };
+
+            # Everything else will be skipped.
+            }
+        elsif ($unformattedText)
+            {
+            $unformattedText .= ' ' . $line;
+            };
+
+        $i++;
+        };
+
+    if ($unformattedText)
+        {
+        $$unformattedTextPtr .= $self->FormatText($unformattedText) . $unformattedTextCloser;
+        $unformattedText = undef;
+        };
+
+    if ($params)
+        {  $output .= '<h>Parameters</h><dl>' . $params . '</dl>';  };
+    if ($returns)
+        {  $output .= '<h>Returns</h><p>' . $returns . '</p>';  };
+    if ($throws)
+        {  $output .= '<h>Throws</h><dl>' . $throws . '</dl>';  };
+    if ($since)
+        {  $output .= '<h>Since</h><p>' . $since . '</p>';  };
+    if ($version)
+        {  $output .= '<h>Version</h><p>' . $version . '</p>';  };
+    if ($deprecation)
+        {  $output .= '<h>Deprecated</h><p>' . $deprecation . '</p>';  };
+    if ($authors)
+        {  $output .= '<h>Author</h><p>' . $authors . '</p>';  };
+    if ($seeAlso)
+        {  $output .= '<h>See Also</h><p>' . $seeAlso . '</p>';  };
+
+
+    # Stage three: Build the parsed topic.
+
+    my $summary = NaturalDocs::Parser->GetSummaryFromBody($output);
+
+    push @$parsedTopics, NaturalDocs::Parser::ParsedTopic->New(undef, undef, undef, undef, undef, $summary,
+                                                                                                $output, $lineNumber, undef);
+    return 1;
+    };
+
+
+##
+#   Translates any inline tags or HTML codes to <NDMarkup> and returns it.
+#
+sub FormatText #(string text, bool inParagraph)
+    {
+    my ($self, $text, $inParagraph) = @_;
+
+    # JavaDoc Literal
+
+    $text =~ s/\{\@(?:code|literal) ([^\}]*)\}/$self->ConvertAmpChars($1)/gie;
+
+
+    # HTML
+
+    $text =~ s/&lt;b&gt;(.*?)&lt;\/b&gt;/<b>$1<\/b>/gi;
+    $text =~ s/&lt;i&gt;(.*?)&lt;\/i&gt;/<i>$1<\/i>/gi;
+    $text =~ s/&lt;u&gt;(.*?)&lt;\/u&gt;/<u>$1<\/u>/gi;
+
+    $text =~ s/&lt;code&gt;(.*?)&lt;\/code&gt;/$1/gi;
+
+    $text =~ s/&lt;ul.*?&gt;(.*?)&lt;\/ul&gt;/<ul>$1<\/ul>/gi;
+    $text =~ s/&lt;ol.*?&gt;(.*?)&lt;\/ol&gt;/<ul>$1<\/ul>/gi;
+    $text =~ s/&lt;li.*?&gt;(.*?)&lt;\/li&gt;/<li>$1<\/li>/gi;
+
+    $text =~ s/&lt;!--.*?--&gt;//gi;
+
+    $text =~ s/&lt;\/p&gt;//gi;
+    $text =~ s/^&lt;p&gt;//i;
+    if ($inParagraph)
+        {  $text =~ s/&lt;p&gt;/<\/p><p>/gi;  }
+    else
+        {  $text =~ s/&lt;p&gt;//gi;  };
+
+    $text =~ s/&lt;a href=&quot;mailto:(.*?)&quot;.*?&gt;(.*?)&lt;\/a&gt;/$self->MakeEMailLink($1, $2)/gie;
+    $text =~ s/&lt;a href=&quot;(.*?)&quot;.*?&gt;(.*?)&lt;\/a&gt;/$self->MakeURLLink($1, $2)/gie;
+
+    $text =~ s/&amp;nbsp;/ /gi;
+    $text =~ s/&amp;amp;/&amp;/gi;
+    $text =~ s/&amp;gt;/&gt;/gi;
+    $text =~ s/&amp;lt;/&lt;/gi;
+    $text =~ s/&amp;quot;/&quot;/gi;
+
+
+
+    # JavaDoc
+
+    $text =~ s/\{\@inheritdoc\}//gi;
+    $text =~ s/\{\@(?:linkplain|link|value) ([^\}]*)\}/$self->ConvertLink($1)/gie;
+
+    return $text;
+    };
+
+
+sub ConvertAmpChars #(text)
+    {
+    my ($self, $text) = @_;
+
+    $text =~ s/&/&amp;/g;
+    $text =~ s/</&lt;/g;
+    $text =~ s/>/&gt;/g;
+    $text =~ s/"/&quot;/g;
+
+    return $text;
+    };
+
+sub ConvertLink #(text)
+    {
+    my ($self, $text) = @_;
+
+    $text =~ /^ *([a-z0-9\_\.\:\#]+(?:\([^\)]*\))?) *(.*)$/i;
+    my ($target, $label) = ($1, $2);
+
+    # Convert the anchor to part of the link, but remove it altogether if it's the beginning of the link.
+    $target =~ s/^\#//;
+    $target =~ s/\#/\./;
+
+    $label =~ s/ +$//;
+
+    if (!length $label)
+        {  return '<link target="' . $target . '" name="' . $target . '" original="' . $target . '">';  }
+    else
+        {  return '<link target="' . $target . '" name="' . $label . '" original="' . $label . ' (' . $target . ')">';  };
+    };
+
+sub MakeURLLink #(target, text)
+    {
+    my ($self, $target, $text) = @_;
+    return '<url target="' . $target . '" name="' . $text . '">';
+    };
+
+sub MakeEMailLink #(target, text)
+    {
+    my ($self, $target, $text) = @_;
+    return '<email target="' . $target . '" name="' . $text . '">';
+    };
+
+
+1;
diff --git a/docs/tool/Modules/NaturalDocs/Parser/Native.pm b/docs/tool/Modules/NaturalDocs/Parser/Native.pm
new file mode 100644
index 00000000..61ba97e5
--- /dev/null
+++ b/docs/tool/Modules/NaturalDocs/Parser/Native.pm
@@ -0,0 +1,1060 @@
+###############################################################################
+#
+#   Package: NaturalDocs::Parser::Native
+#
+###############################################################################
+#
+#   A package that converts comments from Natural Docs' native format into <NaturalDocs::Parser::ParsedTopic> objects.
+#   Unlike most second-level packages, these are packages and not object classes.
+#
+###############################################################################
+
+# This file is part of Natural Docs, which is Copyright (C) 2003-2008 Greg Valure
+# Natural Docs is licensed under the GPL
+
+
+use strict;
+use integer;
+
+package NaturalDocs::Parser::Native;
+
+
+###############################################################################
+# Group: Variables
+
+
+# Return values of TagType().  Not documented here.
+use constant POSSIBLE_OPENING_TAG => 1;
+use constant POSSIBLE_CLOSING_TAG => 2;
+use constant NOT_A_TAG => 3;
+
+
+#
+#   var: package
+#
+#   A <SymbolString> representing the package normal topics will be a part of at the current point in the file.  This is a package variable
+#   because it needs to be reserved between function calls.
+#
+my $package;
+
+#
+#   hash: functionListIgnoredHeadings
+#
+#   An existence hash of all the headings that prevent the parser from creating function list symbols.  Whenever one of
+#   these headings are used in a function list topic, symbols are not created from definition lists until the next heading.  The keys
+#   are in all lowercase.
+#
+my %functionListIgnoredHeadings = ( 'parameters' => 1,
+                                                       'parameter' => 1,
+                                                       'params' => 1,
+                                                       'param' => 1,
+                                                       'arguments' => 1,
+                                                       'argument' => 1,
+                                                       'args' => 1,
+                                                       'arg' => 1 );
+
+
+###############################################################################
+# Group: Interface Functions
+
+
+#
+#   Function: Start
+#
+#   This will be called whenever a file is about to be parsed.  It allows the package to reset its internal state.
+#
+sub Start
+    {
+    my ($self) = @_;
+    $package = undef;
+    };
+
+
+#
+#   Function: IsMine
+#
+#   Examines the comment and returns whether it is *definitely* Natural Docs content, i.e. it is owned by this package.  Note
+#   that a comment can fail this function and still be interpreted as a Natural Docs content, for example a JavaDoc-styled comment
+#   that doesn't have header lines but no JavaDoc tags either.
+#
+#   Parameters:
+#
+#       commentLines - An arrayref of the comment lines.  Must have been run through <NaturalDocs::Parser->CleanComment()>.
+#       isJavaDoc - Whether the comment was JavaDoc-styled.
+#
+#   Returns:
+#
+#       Whether the comment is *definitely* Natural Docs content.
+#
+sub IsMine #(string[] commentLines, bool isJavaDoc)
+    {
+    my ($self, $commentLines, $isJavaDoc) = @_;
+
+    # Skip to the first line with content.
+    my $line = 0;
+
+    while ($line < scalar @$commentLines && !length $commentLines->[$line])
+        {  $line++;  };
+
+    return $self->ParseHeaderLine($commentLines->[$line]);
+    };
+
+
+
+#
+#   Function: ParseComment
+#
+#   This will be called whenever a comment capable of containing Natural Docs content is found.
+#
+#   Parameters:
+#
+#       commentLines - An arrayref of the comment lines.  Must have been run through <NaturalDocs::Parser->CleanComment()>.
+#                               *The original memory will be changed.*
+#       isJavaDoc - Whether the comment is JavaDoc styled.
+#       lineNumber - The line number of the first of the comment lines.
+#       parsedTopics - A reference to the array where any new <NaturalDocs::Parser::ParsedTopics> should be placed.
+#
+#   Returns:
+#
+#       The number of parsed topics added to the array, or zero if none.
+#
+sub ParseComment #(commentLines, isJavaDoc, lineNumber, parsedTopics)
+    {
+    my ($self, $commentLines, $isJavaDoc, $lineNumber, $parsedTopics) = @_;
+
+    my $topicCount = 0;
+    my $prevLineBlank = 1;
+    my $inCodeSection = 0;
+
+    my ($type, $scope, $isPlural, $title, $symbol);
+    #my $package;  # package variable.
+    my ($newKeyword, $newTitle);
+
+    my $index = 0;
+
+    my $bodyStart = 0;
+    my $bodyEnd = 0;  # Not inclusive.
+
+    while ($index < scalar @$commentLines)
+        {
+        # Everything but leading whitespace was removed beforehand.
+
+        # If we're in a code section...
+        if ($inCodeSection)
+            {
+            if ($commentLines->[$index] =~ /^ *\( *(?:end|finish|done)(?: +(?:table|code|example|diagram))? *\)$/i)
+                {  $inCodeSection = undef;  };
+
+            $prevLineBlank = 0;
+            $bodyEnd++;
+            }
+
+        # If the line is empty...
+        elsif (!length($commentLines->[$index]))
+            {
+            $prevLineBlank = 1;
+
+            if ($topicCount)
+                {  $bodyEnd++;  };
+            }
+
+        # If the line has a recognized header and the previous line is blank...
+        elsif ($prevLineBlank && (($newKeyword, $newTitle) = $self->ParseHeaderLine($commentLines->[$index])) )
+            {
+            # Process the previous one, if any.
+
+            if ($topicCount)
+                {
+                if ($scope == ::SCOPE_START() || $scope == ::SCOPE_END())
+                    {  $package = undef;  };
+
+                my $body = $self->FormatBody($commentLines, $bodyStart, $bodyEnd, $type, $isPlural);
+                my $newTopic = $self->MakeParsedTopic($type, $title, $package, $body, $lineNumber + $bodyStart - 1, $isPlural);
+                push @$parsedTopics, $newTopic;
+
+                $package = $newTopic->Package();
+                };
+
+            $title = $newTitle;
+
+            my $typeInfo;
+            ($type, $typeInfo, $isPlural) = NaturalDocs::Topics->KeywordInfo($newKeyword);
+            $scope = $typeInfo->Scope();
+
+            $bodyStart = $index + 1;
+            $bodyEnd = $index + 1;
+
+            $topicCount++;
+
+            $prevLineBlank = 0;
+            }
+
+        # If we're on a non-empty, non-header line of a JavaDoc-styled comment and we haven't started a topic yet...
+        elsif ($isJavaDoc && !$topicCount)
+            {
+            $type = undef;
+            $scope = ::SCOPE_NORMAL();  # The scope repair and topic merging processes will handle if this is a class topic.
+            $isPlural = undef;
+            $title = undef;
+            $symbol = undef;
+
+            $bodyStart = $index;
+            $bodyEnd = $index + 1;
+
+            $topicCount++;
+
+            $prevLineBlank = undef;
+            }
+
+        # If we're on a normal content line within a topic
+        elsif ($topicCount)
+            {
+            $prevLineBlank = 0;
+            $bodyEnd++;
+
+            if ($commentLines->[$index] =~ /^ *\( *(?:(?:start|begin)? +)?(?:table|code|example|diagram) *\)$/i)
+                {  $inCodeSection = 1;  };
+            };
+
+
+        $index++;
+        };
+
+
+    # Last one, if any.  This is the only one that gets the prototypes.
+    if ($bodyStart)
+        {
+        if ($scope == ::SCOPE_START() || $scope == ::SCOPE_END())
+            {  $package = undef;  };
+
+        my $body = $self->FormatBody($commentLines, $bodyStart, $bodyEnd, $type, $isPlural);
+        my $newTopic = $self->MakeParsedTopic($type, $title, $package, $body, $lineNumber + $bodyStart - 1, $isPlural);
+        push @$parsedTopics, $newTopic;
+        $topicCount++;
+
+        $package = $newTopic->Package();
+        };
+
+    return $topicCount;
+    };
+
+
+#
+#   Function: ParseHeaderLine
+#
+#   If the passed line is a topic header, returns the array ( keyword, title ).  Otherwise returns an empty array.
+#
+sub ParseHeaderLine #(line)
+    {
+    my ($self, $line) = @_;
+
+    if ($line =~ /^ *([a-z0-9 ]*[a-z0-9]): +(.*)$/i)
+        {
+        my ($keyword, $title) = ($1, $2);
+
+        # We need to do it this way because if you do "if (ND:T->KeywordInfo($keyword)" and the last element of the array it
+        # returns is false, the statement is false.  That is really retarded, but there it is.
+        my ($type, undef, undef) = NaturalDocs::Topics->KeywordInfo($keyword);
+
+        if ($type)
+            {  return ($keyword, $title);  }
+        else
+            {  return ( );  };
+        }
+    else
+        {  return ( );  };
+    };
+
+
+
+###############################################################################
+# Group: Support Functions
+
+
+#
+#   Function: MakeParsedTopic
+#
+#   Creates a <NaturalDocs::Parser::ParsedTopic> object for the passed parameters.  Scope is gotten from
+#   the package variable <package> instead of from the parameters.  The summary is generated from the body.
+#
+#   Parameters:
+#
+#       type         - The <TopicType>.  May be undef for headerless topics.
+#       title          - The title of the topic.  May be undef for headerless topics.
+#       package    - The package <SymbolString> the topic appears in.
+#       body        - The topic's body in <NDMarkup>.
+#       lineNumber - The topic's line number.
+#       isList         - Whether the topic is a list.
+#
+#   Returns:
+#
+#       The <NaturalDocs::Parser::ParsedTopic> object.
+#
+sub MakeParsedTopic #(type, title, package, body, lineNumber, isList)
+    {
+    my ($self, $type, $title, $package, $body, $lineNumber, $isList) = @_;
+
+    my $summary;
+
+    if (defined $body)
+        {  $summary = NaturalDocs::Parser->GetSummaryFromBody($body);  };
+
+    return NaturalDocs::Parser::ParsedTopic->New($type, $title, $package, undef, undef, $summary,
+                                                                         $body, $lineNumber, $isList);
+    };
+
+
+#
+#    Function: FormatBody
+#
+#    Converts the section body to <NDMarkup>.
+#
+#    Parameters:
+#
+#       commentLines - The arrayref of comment lines.
+#       startingIndex  - The starting index of the body to format.
+#       endingIndex   - The ending index of the body to format, *not* inclusive.
+#       type               - The type of the section.  May be undef for headerless comments.
+#       isList              - Whether it's a list topic.
+#
+#    Returns:
+#
+#        The body formatted in <NDMarkup>.
+#
+sub FormatBody #(commentLines, startingIndex, endingIndex, type, isList)
+    {
+    my ($self, $commentLines, $startingIndex, $endingIndex, $type, $isList) = @_;
+
+    use constant TAG_NONE => 1;
+    use constant TAG_PARAGRAPH => 2;
+    use constant TAG_BULLETLIST => 3;
+    use constant TAG_DESCRIPTIONLIST => 4;
+    use constant TAG_HEADING => 5;
+    use constant TAG_PREFIXCODE => 6;
+    use constant TAG_TAGCODE => 7;
+
+    my %tagEnders = ( TAG_NONE() => '',
+                                 TAG_PARAGRAPH() => '</p>',
+                                 TAG_BULLETLIST() => '</li></ul>',
+                                 TAG_DESCRIPTIONLIST() => '</dd></dl>',
+                                 TAG_HEADING() => '</h>',
+                                 TAG_PREFIXCODE() => '</code>',
+                                 TAG_TAGCODE() => '</code>' );
+
+    my $topLevelTag = TAG_NONE;
+
+    my $output;
+    my $textBlock;
+    my $prevLineBlank = 1;
+
+    my $codeBlock;
+    my $removedCodeSpaces;
+
+    my $ignoreListSymbols;
+
+    my $index = $startingIndex;
+
+    while ($index < $endingIndex)
+        {
+        # If we're in a tagged code section...
+        if ($topLevelTag == TAG_TAGCODE)
+            {
+            if ($commentLines->[$index] =~ /^ *\( *(?:end|finish|done)(?: +(?:table|code|example|diagram))? *\)$/i)
+                {
+                $codeBlock =~ s/\n+$//;
+                $output .= NaturalDocs::NDMarkup->ConvertAmpChars($codeBlock) . '</code>';
+                $codeBlock = undef;
+                $topLevelTag = TAG_NONE;
+                $prevLineBlank = undef;
+                }
+            else
+                {
+                $self->AddToCodeBlock($commentLines->[$index], \$codeBlock, \$removedCodeSpaces);
+                };
+            }
+
+        # If the line starts with a code designator...
+        elsif ($commentLines->[$index] =~ /^ *[>:|](.*)$/)
+            {
+            my $code = $1;
+
+            if ($topLevelTag == TAG_PREFIXCODE)
+                {
+                $self->AddToCodeBlock($code, \$codeBlock, \$removedCodeSpaces);
+                }
+            else # $topLevelTag != TAG_PREFIXCODE
+                {
+                if (defined $textBlock)
+                    {
+                    $output .= $self->RichFormatTextBlock($textBlock) . $tagEnders{$topLevelTag};
+                    $textBlock = undef;
+                    };
+
+                $topLevelTag = TAG_PREFIXCODE;
+                $output .= '<code>';
+                $self->AddToCodeBlock($code, \$codeBlock, \$removedCodeSpaces);
+                };
+            }
+
+        # If we're not in either code style...
+        else
+            {
+            # Strip any leading whitespace.
+            $commentLines->[$index] =~ s/^ +//;
+
+            # If we were in a prefixed code section...
+            if ($topLevelTag == TAG_PREFIXCODE)
+                {
+                $codeBlock =~ s/\n+$//;
+                $output .= NaturalDocs::NDMarkup->ConvertAmpChars($codeBlock) . '</code>';
+                $codeBlock = undef;
+                $topLevelTag = TAG_NONE;
+                $prevLineBlank = undef;
+                };
+
+
+            # If the line is blank...
+            if (!length($commentLines->[$index]))
+                {
+                # End a paragraph.  Everything else ignores it for now.
+                if ($topLevelTag == TAG_PARAGRAPH)
+                    {
+                    $output .= $self->RichFormatTextBlock($textBlock) . '</p>';
+                    $textBlock = undef;
+                    $topLevelTag = TAG_NONE;
+                    };
+
+                $prevLineBlank = 1;
+                }
+
+            # If the line starts with a bullet...
+            elsif ($commentLines->[$index] =~ /^[-\*o+] +([^ ].*)$/ &&
+                    substr($1, 0, 2) ne '- ')  # Make sure "o - Something" is a definition, not a bullet.
+                {
+                my $bulletedText = $1;
+
+                if (defined $textBlock)
+                    {  $output .= $self->RichFormatTextBlock($textBlock);  };
+
+                if ($topLevelTag == TAG_BULLETLIST)
+                    {
+                    $output .= '</li><li>';
+                    }
+                else #($topLevelTag != TAG_BULLETLIST)
+                    {
+                    $output .= $tagEnders{$topLevelTag} . '<ul><li>';
+                    $topLevelTag = TAG_BULLETLIST;
+                    };
+
+                $textBlock = $bulletedText;
+
+                $prevLineBlank = undef;
+                }
+
+            # If the line looks like a description list entry...
+            elsif ($commentLines->[$index] =~ /^(.+?) +- +([^ ].*)$/ && $topLevelTag != TAG_PARAGRAPH)
+                {
+                my $entry = $1;
+                my $description = $2;
+
+                if (defined $textBlock)
+                    {  $output .= $self->RichFormatTextBlock($textBlock);  };
+
+                if ($topLevelTag == TAG_DESCRIPTIONLIST)
+                    {
+                    $output .= '</dd>';
+                    }
+                else #($topLevelTag != TAG_DESCRIPTIONLIST)
+                    {
+                    $output .= $tagEnders{$topLevelTag} . '<dl>';
+                    $topLevelTag = TAG_DESCRIPTIONLIST;
+                    };
+
+                if (($isList && !$ignoreListSymbols) || $type eq ::TOPIC_ENUMERATION())
+                    {
+                    $output .= '<ds>' . NaturalDocs::NDMarkup->ConvertAmpChars($entry) . '</ds><dd>';
+                    }
+                else
+                    {
+                    $output .= '<de>' . NaturalDocs::NDMarkup->ConvertAmpChars($entry) . '</de><dd>';
+                    };
+
+                $textBlock = $description;
+
+                $prevLineBlank = undef;
+                }
+
+            # If the line could be a header...
+            elsif ($prevLineBlank && $commentLines->[$index] =~ /^(.*)([^ ]):$/)
+                {
+                my $headerText = $1 . $2;
+
+                if (defined $textBlock)
+                    {
+                    $output .= $self->RichFormatTextBlock($textBlock);
+                    $textBlock = undef;
+                    }
+
+                $output .= $tagEnders{$topLevelTag};
+                $topLevelTag = TAG_NONE;
+
+                $output .= '<h>' . $self->RichFormatTextBlock($headerText) . '</h>';
+
+                if ($type eq ::TOPIC_FUNCTION() && $isList)
+                    {
+                    $ignoreListSymbols = exists $functionListIgnoredHeadings{lc($headerText)};
+                    };
+
+                $prevLineBlank = undef;
+                }
+
+            # If the line looks like a code tag...
+            elsif ($commentLines->[$index] =~ /^\( *(?:(?:start|begin)? +)?(?:table|code|example|diagram) *\)$/i)
+                {
+                if (defined $textBlock)
+                    {
+                    $output .= $self->RichFormatTextBlock($textBlock);
+                    $textBlock = undef;
+                    };
+
+                $output .= $tagEnders{$topLevelTag} . '<code>';
+                $topLevelTag = TAG_TAGCODE;
+                }
+
+            # If the line looks like an inline image...
+            elsif ($commentLines->[$index] =~ /^(\( *see +)([^\)]+?)( *\))$/i)
+                {
+                if (defined $textBlock)
+                    {
+                    $output .= $self->RichFormatTextBlock($textBlock);
+                    $textBlock = undef;
+                    };
+
+                $output .= $tagEnders{$topLevelTag};
+                $topLevelTag = TAG_NONE;
+
+                $output .= '<img mode="inline" target="' . NaturalDocs::NDMarkup->ConvertAmpChars($2) . '" '
+                                . 'original="' . NaturalDocs::NDMarkup->ConvertAmpChars($1 . $2 . $3) . '">';
+
+                $prevLineBlank = undef;
+                }
+
+            # If the line isn't any of those, we consider it normal text.
+            else
+                {
+                # A blank line followed by normal text ends lists.  We don't handle this when we detect if the line's blank because
+                # we don't want blank lines between list items to break the list.
+                if ($prevLineBlank && ($topLevelTag == TAG_BULLETLIST || $topLevelTag == TAG_DESCRIPTIONLIST))
+                    {
+                    $output .= $self->RichFormatTextBlock($textBlock) . $tagEnders{$topLevelTag} . '<p>';
+
+                    $topLevelTag = TAG_PARAGRAPH;
+                    $textBlock = undef;
+                    }
+
+                elsif ($topLevelTag == TAG_NONE)
+                    {
+                    $output .= '<p>';
+                    $topLevelTag = TAG_PARAGRAPH;
+                    # textBlock will already be undef.
+                    };
+
+                if (defined $textBlock)
+                    {  $textBlock .= ' ';  };
+
+                $textBlock .= $commentLines->[$index];
+
+                $prevLineBlank = undef;
+                };
+            };
+
+        $index++;
+        };
+
+    # Clean up anything left dangling.
+    if (defined $textBlock)
+        {
+        $output .= $self->RichFormatTextBlock($textBlock) . $tagEnders{$topLevelTag};
+        }
+    elsif (defined $codeBlock)
+        {
+        $codeBlock =~ s/\n+$//;
+        $output .= NaturalDocs::NDMarkup->ConvertAmpChars($codeBlock) . '</code>';
+        };
+
+    return $output;
+    };
+
+
+#
+#   Function: AddToCodeBlock
+#
+#   Adds a line of text to a code block, handling all the indentation processing required.
+#
+#   Parameters:
+#
+#       line - The line of text to add.
+#       codeBlockRef - A reference to the code block to add it to.
+#       removedSpacesRef - A reference to a variable to hold the number of spaces removed.  It needs to be stored between calls.
+#                                      It will reset itself automatically when the code block codeBlockRef points to is undef.
+#
+sub AddToCodeBlock #(line, codeBlockRef, removedSpacesRef)
+    {
+    my ($self, $line, $codeBlockRef, $removedSpacesRef) = @_;
+
+    $line =~ /^( *)(.*)$/;
+    my ($spaces, $code) = ($1, $2);
+
+    if (!defined $$codeBlockRef)
+        {
+        if (length($code))
+            {
+            $$codeBlockRef = $code . "\n";
+            $$removedSpacesRef = length($spaces);
+            };
+        # else ignore leading line breaks.
+        }
+
+    elsif (length $code)
+        {
+        # Make sure we have the minimum amount of spaces to the left possible.
+        if (length($spaces) != $$removedSpacesRef)
+            {
+            my $spaceDifference = abs( length($spaces) - $$removedSpacesRef );
+            my $spacesToAdd = ' ' x $spaceDifference;
+
+            if (length($spaces) > $$removedSpacesRef)
+                {
+                $$codeBlockRef .= $spacesToAdd;
+                }
+            else
+                {
+                $$codeBlockRef =~ s/^(.)/$spacesToAdd . $1/gme;
+                $$removedSpacesRef = length($spaces);
+                };
+            };
+
+        $$codeBlockRef .= $code . "\n";
+        }
+
+    else # (!length $code)
+        {
+        $$codeBlockRef .= "\n";
+        };
+    };
+
+
+#
+#   Function: RichFormatTextBlock
+#
+#   Applies rich <NDMarkup> formatting to a chunk of text.  This includes both amp chars, formatting tags, and link tags.
+#
+#   Parameters:
+#
+#       text - The block of text to format.
+#
+#   Returns:
+#
+#       The formatted text block.
+#
+sub RichFormatTextBlock #(text)
+    {
+    my ($self, $text) = @_;
+    my $output;
+
+
+    # First find bare urls, e-mail addresses, and images.  We have to do this before the split because they may contain underscores
+    # or asterisks.  We have to mark the tags with \x1E and \x1F so they don't get confused with angle brackets from the comment.
+    # We can't convert the amp chars beforehand because we need lookbehinds in the regexps below and they need to be
+    # constant length.  Sucks, huh?
+
+    $text =~ s{
+                       # The previous character can't be an alphanumeric or an opening angle bracket.
+                       (?<!  [a-z0-9<]  )
+
+                       # Optional mailto:.  Ignored in output.
+                       (?:mailto\:)?
+
+                       # Begin capture
+                       (
+
+                       # The user portion.  Alphanumeric and - _.  Dots can appear between, but not at the edges or more than
+                       # one in a row.
+                       (?:  [a-z0-9\-_]+  \.  )*   [a-z0-9\-_]+
+
+                       @
+
+                       # The domain.  Alphanumeric and -.  Dots same as above, however, there must be at least two sections
+                       # and the last one must be two to four alphanumeric characters (.com, .uk, .info, .203 for IP addresses)
+                       (?:  [a-z0-9\-]+  \.  )+  [a-z]{2,4}
+
+                       # End capture.
+                       )
+
+                       # The next character can't be an alphanumeric, which should prevent .abcde from matching the two to
+                       # four character requirement, or a closing angle bracket.
+                       (?!  [a-z0-9>]  )
+
+                       }
+
+                       {"\x1E" . 'email target="' . NaturalDocs::NDMarkup->ConvertAmpChars($1) . '" '
+                       . 'name="' . NaturalDocs::NDMarkup->ConvertAmpChars($1) . '"' . "\x1F"}igxe;
+
+    $text =~ s{
+                       # The previous character can't be an alphanumeric or an opening angle bracket.
+                       (?<!  [a-z0-9<]  )
+
+                       # Begin capture.
+                       (
+
+                       # URL must start with one of the acceptable protocols.
+                       (?:http|https|ftp|news|file)\:
+
+                       # The acceptable URL characters as far as I know.
+                       [a-z0-9\-\=\~\@\#\%\&\_\+\/\;\:\?\*\.\,]*
+
+                       # The URL characters minus period and comma.  If it ends on them, they're probably intended as
+                       # punctuation.
+                       [a-z0-9\-\=\~\@\#\%\&\_\+\/\;\:\?\*]
+
+                       # End capture.
+                       )
+
+                       # The next character must not be an acceptable character or a closing angle bracket.  This will prevent the URL
+                       # from ending early just to get a match.
+                       (?!  [a-z0-9\-\=\~\@\#\%\&\_\+\/\;\:\?\*\>]  )
+
+                       }
+
+                       {"\x1E" . 'url target="' . NaturalDocs::NDMarkup->ConvertAmpChars($1) . '" '
+                       . 'name="' . NaturalDocs::NDMarkup->ConvertAmpChars($1) . '"' . "\x1F"}igxe;
+
+
+    # Find image links.  Inline images should already be pulled out by now.
+
+    $text =~ s{(\( *see +)([^\)]+?)( *\))}
+                      {"\x1E" . 'img mode="link" target="' . NaturalDocs::NDMarkup->ConvertAmpChars($2) . '" '
+                        . 'original="' . NaturalDocs::NDMarkup->ConvertAmpChars($1 . $2 . $3) . '"' . "\x1F"}gie;
+
+
+
+    # Split the text from the potential tags.
+
+    my @tempTextBlocks = split(/([\*_<>\x1E\x1F])/, $text);
+
+    # Since the symbols are considered dividers, empty strings could appear between two in a row or at the beginning/end of the
+    # array.  This could seriously screw up TagType(), so we need to get rid of them.
+    my @textBlocks;
+
+    while (scalar @tempTextBlocks)
+        {
+        my $tempTextBlock = shift @tempTextBlocks;
+
+        if (length $tempTextBlock)
+            {  push @textBlocks, $tempTextBlock;  };
+        };
+
+
+    my $bold;
+    my $underline;
+    my $underlineHasWhitespace;
+
+    my $index = 0;
+
+    while ($index < scalar @textBlocks)
+        {
+        if ($textBlocks[$index] eq "\x1E")
+            {
+            $output .= '<';
+            $index++;
+
+            while ($textBlocks[$index] ne "\x1F")
+                {
+                $output .= $textBlocks[$index];
+                $index++;
+                };
+
+            $output .= '>';
+            }
+
+        elsif ($textBlocks[$index] eq '<' && $self->TagType(\@textBlocks, $index) == POSSIBLE_OPENING_TAG)
+            {
+            my $endingIndex = $self->ClosingTag(\@textBlocks, $index, undef);
+
+            if ($endingIndex != -1)
+                {
+                my $linkText;
+                $index++;
+
+                while ($index < $endingIndex)
+                    {
+                    $linkText .= $textBlocks[$index];
+                    $index++;
+                    };
+                # Index will be incremented again at the end of the loop.
+
+                $linkText = NaturalDocs::NDMarkup->ConvertAmpChars($linkText);
+
+                if ($linkText =~ /^(?:mailto\:)?((?:[a-z0-9\-_]+\.)*[a-z0-9\-_]+@(?:[a-z0-9\-]+\.)+[a-z]{2,4})$/i)
+                    {  $output .= '<email target="' . $1 . '" name="' . $1 . '">';  }
+                elsif ($linkText =~ /^(?:http|https|ftp|news|file)\:/i)
+                    {  $output .= '<url target="' . $linkText . '" name="' . $linkText . '">';  }
+                else
+                    {  $output .= '<link target="' . $linkText . '" name="' . $linkText . '" original="&lt;' . $linkText . '&gt;">';  };
+                }
+
+            else # it's not a link.
+                {
+                $output .= '&lt;';
+                };
+            }
+
+        elsif ($textBlocks[$index] eq '*')
+            {
+            my $tagType = $self->TagType(\@textBlocks, $index);
+
+            if ($tagType == POSSIBLE_OPENING_TAG && $self->ClosingTag(\@textBlocks, $index, undef) != -1)
+                {
+                # ClosingTag() makes sure tags aren't opened multiple times in a row.
+                $bold = 1;
+                $output .= '<b>';
+                }
+            elsif ($bold && $tagType == POSSIBLE_CLOSING_TAG)
+                {
+                $bold = undef;
+                $output .= '</b>';
+                }
+            else
+                {
+                $output .= '*';
+                };
+            }
+
+        elsif ($textBlocks[$index] eq '_')
+            {
+            my $tagType = $self->TagType(\@textBlocks, $index);
+
+             if ($tagType == POSSIBLE_OPENING_TAG && $self->ClosingTag(\@textBlocks, $index, \$underlineHasWhitespace) != -1)
+                {
+                # ClosingTag() makes sure tags aren't opened multiple times in a row.
+                $underline = 1;
+                #underlineHasWhitespace is set by ClosingTag().
+                $output .= '<u>';
+                }
+            elsif ($underline && $tagType == POSSIBLE_CLOSING_TAG)
+                {
+                $underline = undef;
+                #underlineHasWhitespace will be reset by the next opening underline.
+                $output .= '</u>';
+                }
+            elsif ($underline && !$underlineHasWhitespace)
+                {
+                # If there's no whitespace between underline tags, all underscores are replaced by spaces so
+                # _some_underlined_text_ becomes <u>some underlined text</u>.  The standard _some underlined text_
+                # will work too.
+                $output .= ' ';
+                }
+            else
+                {
+                $output .= '_';
+                };
+            }
+
+        else # plain text or a > that isn't part of a link
+            {
+            $output .= NaturalDocs::NDMarkup->ConvertAmpChars($textBlocks[$index]);
+           };
+
+        $index++;
+        };
+
+    return $output;
+    };
+
+
+#
+#   Function: TagType
+#
+#   Returns whether the tag is a possible opening or closing tag, or neither.  "Possible" because it doesn't check if an opening tag is
+#   closed or a closing tag is opened, just whether the surrounding characters allow it to be a candidate for a tag.  For example, in
+#   "A _B" the underscore is a possible opening underline tag, but in "A_B" it is not.  Support function for <RichFormatTextBlock()>.
+#
+#   Parameters:
+#
+#       textBlocks  - A reference to an array of text blocks.
+#       index         - The index of the tag.
+#
+#   Returns:
+#
+#       POSSIBLE_OPENING_TAG, POSSIBLE_CLOSING_TAG, or NOT_A_TAG.
+#
+sub TagType #(textBlocks, index)
+    {
+    my ($self, $textBlocks, $index) = @_;
+
+
+    # Possible opening tags
+
+    if ( ( $textBlocks->[$index] =~ /^[\*_<]$/ ) &&
+
+        # Before it must be whitespace, the beginning of the text, or ({["'-/*_.
+        ( $index == 0 || $textBlocks->[$index-1] =~ /[\ \t\n\(\{\[\"\'\-\/\*\_]$/ ) &&
+
+        # Notes for 2.0: Include Spanish upside down ! and ? as well as opening quotes (66) and apostrophes (6).  Look into
+        # Unicode character classes as well.
+
+        # After it must be non-whitespace.
+        ( $index + 1 < scalar @$textBlocks && $textBlocks->[$index+1] !~ /^[\ \t\n]/) &&
+
+        # Make sure we don't accept <<, <=, <-, or *= as opening tags.
+        ( $textBlocks->[$index] ne '<' || $textBlocks->[$index+1] !~ /^[<=-]/ ) &&
+        ( $textBlocks->[$index] ne '*' || $textBlocks->[$index+1] !~ /^[\=\*]/ ) &&
+
+        # Make sure we don't accept * or _ before it unless it's <.
+        ( $textBlocks->[$index] eq '<' || $index == 0 || $textBlocks->[$index-1] !~ /[\*\_]$/) )
+        {
+        return POSSIBLE_OPENING_TAG;
+        }
+
+
+    # Possible closing tags
+
+    elsif ( ( $textBlocks->[$index] =~ /^[\*_>]$/) &&
+
+            # After it must be whitespace, the end of the text, or )}].,!?"';:-/*_.
+            ( $index + 1 == scalar @$textBlocks || $textBlocks->[$index+1] =~ /^[ \t\n\)\]\}\.\,\!\?\"\'\;\:\-\/\*\_]/ ||
+              # Links also get plurals, like <link>s, <linx>es, <link>'s, and <links>'.
+              ( $textBlocks->[$index] eq '>' && $textBlocks->[$index+1] =~ /^(?:es|s|\')/ ) ) &&
+
+            # Notes for 2.0: Include closing quotes (99) and apostrophes (9).  Look into Unicode character classes as well.
+
+            # Before it must be non-whitespace.
+            ( $index != 0 && $textBlocks->[$index-1] !~ /[ \t\n]$/ ) &&
+
+            # Make sure we don't accept >>, ->, or => as closing tags.  >= is already taken care of.
+            ( $textBlocks->[$index] ne '>' || $textBlocks->[$index-1] !~ /[>=-]$/ ) &&
+
+            # Make sure we don't accept * or _ after it unless it's >.
+            ( $textBlocks->[$index] eq '>' || $textBlocks->[$index+1] !~ /[\*\_]$/) )
+        {
+        return POSSIBLE_CLOSING_TAG;
+        }
+
+    else
+        {
+        return NOT_A_TAG;
+        };
+
+    };
+
+
+#
+#   Function: ClosingTag
+#
+#   Returns whether a tag is closed or not, where it's closed if it is, and optionally whether there is any whitespace between the
+#   tags.  Support function for <RichFormatTextBlock()>.
+#
+#   The results of this function are in full context, meaning that if it says a tag is closed, it can be interpreted as that tag in the
+#   final output.  It takes into account any spoiling factors, like there being two opening tags in a row.
+#
+#   Parameters:
+#
+#       textBlocks             - A reference to an array of text blocks.
+#       index                    - The index of the opening tag.
+#       hasWhitespaceRef  - A reference to the variable that will hold whether there is whitespace between the tags or not.  If
+#                                     undef, the function will not check.  If the tag is not closed, the variable will not be changed.
+#
+#   Returns:
+#
+#       If the tag is closed, it returns the index of the closing tag and puts whether there was whitespace between the tags in
+#       hasWhitespaceRef if it was specified.  If the tag is not closed, it returns -1 and doesn't touch the variable pointed to by
+#       hasWhitespaceRef.
+#
+sub ClosingTag #(textBlocks, index, hasWhitespace)
+    {
+    my ($self, $textBlocks, $index, $hasWhitespaceRef) = @_;
+
+    my $hasWhitespace;
+    my $closingTag;
+
+    if ($textBlocks->[$index] eq '*' || $textBlocks->[$index] eq '_')
+        {  $closingTag = $textBlocks->[$index];  }
+    elsif ($textBlocks->[$index] eq '<')
+        {  $closingTag = '>';  }
+    else
+        {  return -1;  };
+
+    my $beginningIndex = $index;
+    $index++;
+
+    while ($index < scalar @$textBlocks)
+        {
+        if ($textBlocks->[$index] eq '<' && $self->TagType($textBlocks, $index) == POSSIBLE_OPENING_TAG)
+            {
+            # If we hit a < and we're checking whether a link is closed, it's not.  The first < becomes literal and the second one
+            # becomes the new link opening.
+            if ($closingTag eq '>')
+                {
+                return -1;
+                }
+
+            # If we're not searching for the end of a link, we have to skip the link because formatting tags cannot appear within
+            # them.  That's of course provided it's closed.
+            else
+                {
+                my $linkHasWhitespace;
+
+                my $endIndex = $self->ClosingTag($textBlocks, $index,
+                                                                    ($hasWhitespaceRef && !$hasWhitespace ? \$linkHasWhitespace : undef) );
+
+                if ($endIndex != -1)
+                    {
+                    if ($linkHasWhitespace)
+                        {  $hasWhitespace = 1;  };
+
+                    # index will be incremented again at the end of the loop, which will bring us past the link's >.
+                    $index = $endIndex;
+                    };
+                };
+            }
+
+        elsif ($textBlocks->[$index] eq $closingTag)
+            {
+            my $tagType = $self->TagType($textBlocks, $index);
+
+            if ($tagType == POSSIBLE_CLOSING_TAG)
+                {
+                # There needs to be something between the tags for them to count.
+                if ($index == $beginningIndex + 1)
+                    {  return -1;  }
+                else
+                    {
+                    # Success!
+
+                    if ($hasWhitespaceRef)
+                        {  $$hasWhitespaceRef = $hasWhitespace;  };
+
+                    return $index;
+                    };
+                }
+
+            # If there are two opening tags of the same type, the first becomes literal and the next becomes part of a tag.
+            elsif ($tagType == POSSIBLE_OPENING_TAG)
+                {  return -1;  }
+            }
+
+        elsif ($hasWhitespaceRef && !$hasWhitespace)
+            {
+            if ($textBlocks->[$index] =~ /[ \t\n]/)
+                {  $hasWhitespace = 1;  };
+            };
+
+        $index++;
+        };
+
+    # Hit the end of the text blocks if we're here.
+    return -1;
+    };
+
+
+1;
diff --git a/docs/tool/Modules/NaturalDocs/Parser/ParsedTopic.pm b/docs/tool/Modules/NaturalDocs/Parser/ParsedTopic.pm
new file mode 100644
index 00000000..a08d65ad
--- /dev/null
+++ b/docs/tool/Modules/NaturalDocs/Parser/ParsedTopic.pm
@@ -0,0 +1,253 @@
+###############################################################################
+#
+#   Package: NaturalDocs::Parser::ParsedTopic
+#
+###############################################################################
+#
+#   A class for parsed topics of source files.  Also encompasses some of the <TopicType>-specific behavior.
+#
+###############################################################################
+
+# This file is part of Natural Docs, which is Copyright (C) 2003-2008 Greg Valure
+# Natural Docs is licensed under the GPL
+
+use strict;
+use integer;
+
+package NaturalDocs::Parser::ParsedTopic;
+
+
+###############################################################################
+# Group: Implementation
+
+#
+#   Constants: Members
+#
+#   The object is a blessed arrayref with the following indexes.
+#
+#       TYPE           - The <TopicType>.
+#       TITLE          - The title of the topic.
+#       PACKAGE    - The package <SymbolString> the topic appears in, or undef if none.
+#       USING         - An arrayref of additional package <SymbolStrings> available to the topic via "using" statements, or undef if
+#                           none.
+#       PROTOTYPE - The prototype, if it exists and is applicable.
+#       SUMMARY    - The summary, if it exists.
+#       BODY          - The body of the topic, formatted in <NDMarkup>.  Some topics may not have bodies, and if not, this
+#                           will be undef.
+#       LINE_NUMBER  - The line number the topic appears at in the file.
+#       IS_LIST - Whether the topic is a list.
+#
+use NaturalDocs::DefineMembers 'TYPE', 'TITLE', 'PACKAGE', 'USING', 'PROTOTYPE', 'SUMMARY', 'BODY',
+                                                 'LINE_NUMBER', 'IS_LIST';
+# DEPENDENCY: New() depends on the order of these constants, and that this class is not inheriting any members.
+
+
+#
+#   Architecture: Title, Package, and Symbol Behavior
+#
+#   Title, package, and symbol behavior is a little awkward so it deserves some explanation.  Basically you set them according to
+#   certain rules, but you get computed values that try to hide all the different scoping situations.
+#
+#   Normal Topics:
+#
+#       Set them to the title and package as they appear.  "Function" and "PkgA.PkgB" will return "Function" for the title,
+#       "PkgA.PkgB" for the package, and "PkgA.PkgB.Function" for the symbol.
+#
+#       In the rare case that a title has a separator symbol it's treated as inadvertant, so "A vs. B" in "PkgA.PkgB" still returns just
+#       "PkgA.PkgB" for the package even though if you got it from the symbol it can be seen as "PkgA.PkgB.A vs".
+#
+#   Scope Topics:
+#
+#       Set the title normally and leave the package undef.  So "PkgA.PkgB" and undef will return "PkgA.PkgB" for the title as well
+#       as for the package and symbol.
+#
+#       The only time you should set the package is when you have full language support and they only documented the class with
+#       a partial title.  So if you documented "PkgA.PkgB" with just "PkgB", you want to set the package to "PkgA".  This
+#       will return "PkgB" as the title for presentation and will return "PkgA.PkgB" for the package and symbol, which is correct.
+#
+#   Always Global Topics:
+#
+#       Set the title and package normally, do not set the package to undef.  So "Global" and "PkgA.PkgB" will return "Global" as
+#       the title, "PkgA.PkgB" as the package, and "Global" as the symbol.
+#
+#   Um, yeah...:
+#
+#       So does this suck?  Yes, yes it does.  But the suckiness is centralized here instead of having to be handled everywhere these
+#       issues come into play.  Just realize there are a certain set of rules to follow when you *set* these variables, and the results
+#       you see when you *get* them are computed rather than literal.
+#
+
+
+###############################################################################
+# Group: Functions
+
+#
+#   Function: New
+#
+#   Creates a new object.
+#
+#   Parameters:
+#
+#       type          - The <TopicType>.
+#       title           - The title of the topic.
+#       package    - The package <SymbolString> the topic appears in, or undef if none.
+#       using         - An arrayref of additional package <SymbolStrings> available to the topic via "using" statements, or undef if
+#                          none.
+#       prototype   - The prototype, if it exists and is applicable.  Otherwise set to undef.
+#       summary   - The summary of the topic, if any.
+#       body          - The body of the topic, formatted in <NDMarkup>.  May be undef, as some topics may not have bodies.
+#       lineNumber - The line number the topic appears at in the file.
+#       isList          - Whether the topic is a list topic or not.
+#
+#   Returns:
+#
+#       The new object.
+#
+sub New #(type, title, package, using, prototype, summary, body, lineNumber, isList)
+    {
+    # DEPENDENCY: This depends on the order of the parameter list being the same as the constants, and that there are no
+    # members inherited from a base class.
+
+    my $package = shift;
+
+    my $object = [ @_ ];
+    bless $object, $package;
+
+    if (defined $object->[USING])
+        {  $object->[USING] = [ @{$object->[USING]} ];  };
+
+    return $object;
+    };
+
+
+# Function: Type
+# Returns the <TopicType>.
+sub Type
+    {  return $_[0]->[TYPE];  };
+
+# Function: SetType
+# Replaces the <TopicType>.
+sub SetType #(type)
+    {  $_[0]->[TYPE] = $_[1];  };
+
+# Function: IsList
+# Returns whether the topic is a list.
+sub IsList
+    {  return $_[0]->[IS_LIST];  };
+
+# Function: SetIsList
+# Sets whether the topic is a list.
+sub SetIsList
+    {  $_[0]->[IS_LIST] = $_[1];  };
+
+# Function: Title
+# Returns the title of the topic.
+sub Title
+    {  return $_[0]->[TITLE];  };
+
+# Function: SetTitle
+# Replaces the topic title.
+sub SetTitle #(title)
+    {  $_[0]->[TITLE] = $_[1];  };
+
+#
+#   Function: Symbol
+#
+#   Returns the <SymbolString> defined by the topic.  It is fully resolved and does _not_ need to be joined with <Package()>.
+#
+#   Type-Specific Behavior:
+#
+#       - If the <TopicType> is always global, the symbol will be generated from the title only.
+#       - Everything else's symbols will be generated from the title and the package passed to <New()>.
+#
+sub Symbol
+    {
+    my ($self) = @_;
+
+    my $titleSymbol = NaturalDocs::SymbolString->FromText($self->[TITLE]);
+
+    if (NaturalDocs::Topics->TypeInfo($self->Type())->Scope() == ::SCOPE_ALWAYS_GLOBAL())
+        {  return $titleSymbol;  }
+    else
+        {
+        return NaturalDocs::SymbolString->Join( $self->[PACKAGE], $titleSymbol );
+        };
+    };
+
+
+#
+#   Function: Package
+#
+#   Returns the package <SymbolString> that the topic appears in.
+#
+#   Type-Specific Behavior:
+#
+#       - If the <TopicType> has scope, the package will be generated from both the title and the package passed to <New()>, not
+#         just the package.
+#       - If the <TopicType> is always global, the package will be the one passed to <New()>, even though it isn't part of it's
+#         <Symbol()>.
+#       - Everything else's package will be what was passed to <New()>, even if the title has separator symbols in it.
+#
+sub Package
+    {
+    my ($self) = @_;
+
+    # Headerless topics may not have a type yet.
+    if ($self->Type() && NaturalDocs::Topics->TypeInfo($self->Type())->Scope() == ::SCOPE_START())
+        {  return $self->Symbol();  }
+    else
+        {  return $self->[PACKAGE];  };
+    };
+
+
+# Function: SetPackage
+# Replaces the package the topic appears in.  This will behave the same way as the package parameter in <New()>.  Later calls
+# to <Package()> will still be generated according to its type-specific behavior.
+sub SetPackage #(package)
+    {  $_[0]->[PACKAGE] = $_[1];  };
+
+# Function: Using
+# Returns an arrayref of additional scope <SymbolStrings> available to the topic via "using" statements, or undef if none.
+sub Using
+    {  return $_[0]->[USING];  };
+
+# Function: SetUsing
+# Replaces the using arrayref of sope <SymbolStrings>.
+sub SetUsing #(using)
+    {  $_[0]->[USING] = $_[1];  };
+
+# Function: Prototype
+# Returns the prototype if one is defined.  Will be undef otherwise.
+sub Prototype
+    {  return $_[0]->[PROTOTYPE];  };
+
+# Function: SetPrototype
+# Replaces the function or variable prototype.
+sub SetPrototype #(prototype)
+    {  $_[0]->[PROTOTYPE] = $_[1];  };
+
+# Function: Summary
+# Returns the topic summary, if it exists, formatted in <NDMarkup>.
+sub Summary
+    {  return $_[0]->[SUMMARY];  };
+
+# Function: Body
+# Returns the topic's body, formatted in <NDMarkup>.  May be undef.
+sub Body
+    {  return $_[0]->[BODY];  };
+
+# Function: SetBody
+# Replaces the topic's body, formatted in <NDMarkup>.  May be undef.
+sub SetBody #(body)
+    {
+    my ($self, $body) = @_;
+    $self->[BODY] = $body;
+    };
+
+# Function: LineNumber
+# Returns the line the topic appears at in the file.
+sub LineNumber
+    {  return $_[0]->[LINE_NUMBER];  };
+
+
+1;