#!/usr/local/bin/ruby -w
# Does not yet recurse into subdirectories
# Needs to count context (when in class, when in module)

require "rexml/document"

DIRNAME = File.dirname( File.expand_path $0 )
begin
	require "#{DIRNAME}/graph"
	have_graph = true
	puts "GraphR is installed.  Inheritance tree will be generated."
rescue LoadError
	puts "GraphR not installed.  Inheritance tree will not be generated."
	have_graph = false
end

include REXML

@classes = {}		# All of the classes that have been parsed
@modules = {}		# All of the modules that have been parsed
# The following is for catching things that are declared outside of the
# scope of a module or class.
@default = REXML::Document.new 
@default.add_element "class", {"name"=>"overflow" }


if have_graph
	require "#{DIRNAME}/graph"
	@graph = Graph.new
else
	@graph = false
end

Dir.mkdir "api" unless File.exists? "api"

# This is where all of the work is done; one monolithic method.
# The basic procedure is to build a series of XML documents based on
# information read from the source files.  For a given Ruby file, each line is
# read from the source file and checked for certain conditions.  Comments are
# added added to a buffer.  # =begin ... =end comments are ignored.  Classes
# and modules that are encountered cause new documents to be generated and
# stuck in the appropriate @classes / @modules hash.  Other key words trigger
# elements to be added to the appropriate document with the contents of the
# buffer and some contextual meta-data about the trigger.  The buffer is
# cleared after being attached to an object, and the next line is read.
def generate_api fin
	comment = ""
	# The stack array contains the document context; for example, if a document
	# contains a module FOO which contains BAR, if we're parsing a line in BAR,
	# the stack will be two documents; one for FOO, and one for BAR.
	stack = []
	stack.push @default
	# The ends array is used to count the context depth.  We need to know when
	# we leave the context of a class or module, and the only way to determine 
	# this is to count the things that start xxx ... end blocks, and then pop 
	# the stack when we encounter an "end".  This is the most troublesome part 
	# of this process; it is not easy to reliably determine the start and end 
	# conditions without (practically) implementing a Ruby interpreter. This 
	# is why javadoc uses the language parser, rather than doing it the way 
	# API2XML does it.  Granted, API2XML's is less fool-proof, but it is much 
	# easier.
	# In any case, this is how "ends" works: Every block that ends with "end"
	# pushes false onto the ends stack, except for blocks that push a document
	# onto the "stack" stack.  Those push true onto the ends stack.  When an
	# "end" is encountered in the source, "ends" is popped.  If the value is 
	# true, then "stack" is also popped.  In this way, we determine which
	# context we are in.  See the end of the def and class matches for further
	# comments.
	ends = []
	# If this flag is set to true, the next line will not be parsed.
	noparse=false
	for line in fin
		# check for something to comment
		next if noparse and not line=~/^=end/
		# Most things can have end-of-line comments, so we parse these out ahead
		# of time.  We don't actually add them to the comment buffer, because
		# not all comments are relevant to documentation.
		line_comment = /[^#]+#+(.*)/.match(line)[0] if line =~ /^[^#\s]+#/
		# If you want to add a trigger, add a when line and a regular expression
		# that matches the line; make sure it doesn't preempt a later match,
		# please.  And please send additions to ser@germane-software.com, so
		# that everybody can benefit from your genius.  I'll be sure to mention
		# contributions in the documentation.
		# There are a number of actions you can take when you get a match.  The
		# most common is to add an Element to the document tree of the object
		# you're parsing.  See the "alias" match for a detailed example of a
		# common behavior.
		case line

		# COMMENTS
		when /^=begin$/		# So that we don't parse =begin .. =end comments.
			noparse = true		# This could easily be changed so that those


		# COMMENTS
		when /^=end$/			# comments are added to the comment buffer, but this
			noparse = false	# way RD and API2XML documentation can be seperate.


		# SINGLE LINE COMMENTS
		when /^\s*#/			# These are single line comments.
			comment << line.scan(/.*?#+(.*)/)[0][0]

			
		# ALIAS
		when /\balias\s+/		# aliases
			# I'll document this match completely, and skip the line-by-line in
			# further matches.
			# Consume the line comment
			comment << line_comment if line_comment
			# break down the line into the relevant parts
			ali,orig = line.scan(/:(([][<=>]|\w)+)/)
			ali = ali[0]		# The aliased name
			orig = orig[0]		# The name aliased to
			# stack[-1] is the last item on the stack, either a module or a
			# class, because only modules or classes can have aliases. This
			# object is an REXML::Document, so we add new element that looks
			# like: <alias alias='foo' orig='bar'/>
			tag = stack[-1].root.add_element "alias",{'alias'=>ali,'orig'=>orig}
			# Now add the comment to the element, and we get:
			# <alias alias='foo' orig='bar'>Ibi nullus timet mortem, sed pro
			# baccho mittunt sortum.</alias>
			tag.text = comment
			# We have to reset the comment line after we consume it.  Don't
			# forget this!
			comment = ""


		# ATTRIBUTES
		when /\battr_\w+\s+/				# Attribute accessors
			comment << line_comment if line_comment
			rwa = line.scan(/\battr_(\w+)/)[0][0]
			names = line.scan(/:(\w+)/)
			names.each { |at|
			  name = at[0]
			  attr=Element.new "attribute"
			  attr.attributes['name'] = name
			  attr.attributes['type'] = rwa
			  attr.text = comment unless comment.size==0
			  stack[-1].root.elements << attr
			}
			comment = ""


		# CLASS DEFINITIONS
		when /\bclass\s+/		# class definitions
			comment << line_comment if line_comment
			name, opt, extends = line.scan(/\bclass\s+(\w+)(\s*<\s*(\w+))?/)[0]
			# create new document for class if it doesn't already exist
			if @classes[name].nil?
				@classes[name] = Document.new
				@classes[name] << DocType.new( "class" )
				@classes[name].add_element "class"
				@classes[name].root.attributes['name'] = name
				@classes[name].root.attributes['extends'] = extends if extends
			end
			# add class description to class document
			@classes[name].root.text = comment unless comment.size==0
			# add class link to module document
			if stack.size > 0 and stack[-1].root.name == "module"
				link = stack[-1].root.add_element 'class'
				link.attributes['ref'] = name
			end
			comment = ""
			stack << @classes[name]
			# Note this line carefully.  You MUST push true onto the "ends" stack
			# if you are pushing something onto the "stack" stack.  I'll
			# encapsulate the two into a class eventually to avoid confusion.
			ends << true
			#puts "(#{ends.size}) #{line}"


		# METHOD DEFINITIONS
		when /\bdef\s+/		# method definitions
			comment << line_comment if line_comment
			name, what, arguments = line.scan(/\bdef\s+([][<=>\w:]+)\s*\(?\s*(.*)\s*\)?/)[0]

			# add method description to class document
			method = stack[-1].root.add_element "method", {"name"=>name}

			# Now parse the arguments.  We have to be careful about default
			# values here.
			arglist = nil
			arglist = arguments.scan(/\w+\s*=?\s*["']?[{}@\w]*['"]?/) unless arguments.nil?
			arglist.each{|arg|
				arg, default = arg.scan(/([][<=>\w]+)\s*=\s*['"]?([{}@\w]+)["']?/)[0] if arg =~ /=/
				puts "Error in line:\n#{line}" if arg.nil?
				argument = method.add_element "argument", {"name"=>arg.strip}
				argument.attributes["default"] = default if default
			} unless arglist.nil?

			# Now we go through and parse the comments themselves.  We're looking
			# for keywords, such as @param and @return.  It should be easy to add
			# keywords by using either of the following as a template.  The
			# parsing isn't simple; you have to parse everything up to the
			# (potential) next keyword, and then remove the match from the
			# comment itself.
			# Handle @param
			params = comment.scan(/@p(?:aram)?\s+\w+\s+[^@]*/m)
			params.each { |param_line|
				escaped = Regexp.escape param_line
				comment.sub!(/#{escaped}/, '')
				param,desc = param_line.scan(/@p(?:aram)?\s+(\w+)\s+(.*)/m)[0]
				arg = method.elements["argument[@name='#{param.strip}']"]
				arg.text = desc unless arg.nil?
			}
			# Handle @return
			returns = comment.scan(/@r(?:eturn)?\s+[^@]*/m)[0]
			if returns
				comment.sub!(/#{returns}/,'')
				returns = returns.scan(/@r(?:eturn)?\s+(.*)/m)[0][0]
				method.add_element("return").text = returns
			end
			method.text = comment unless comment.size==0
			comment = ""
			# Note this carefully.  You have to add this line to EVERY match
			# which matches a block ending with "end".
			ends << false
			#puts "(#{ends.size}) #{line}"


		# MODULE DEFINITIONS
		when /\bmodule\s+/	# module definitions
			comment << line_comment if line_comment
			name = line.scan(/\bmodule\s+(\w+)/)[0][0]
			# create or find document for module
			unless @modules[name]
				@modules[name] = Document.new
				@modules[name] << DocType.new( "module" )
				@modules[name].add_element "module", {"name"=>name}
			end
			@modules[name].root.text = comment unless comment.size == 0
			# Add a link to the parent module, if any
			if stack.size > 0 and stack[-1].root.name == "module"
				link = stack[-1].root.add_element 'module'
				link.attributes['ref'] = name
			end
			comment = ""
			stack << @modules[name]
			ends << true
			#puts "(#{ends.size}) #{line}"


		# INCLUDES
		when /\binclude\s+/	# add an inclusion to a class or module
			comment << line_comment if line_comment
			name = line.scan( /\binclude\s+(\w+)/ )[0][0]
			stack[-1].root.add_element("include",{"name"=>name}).text=comment
			comment = ""

			
		# BLOCK STARTERS
		when /^\s*(if|unless|until|begin|while|case)\b/
			# This handles all blocks which are not commented, but which end in
			# the "end" keyword.  We MUST handle these, otherwise our stack will
			# be corrupted.  Note that, while these keywords can be line
			# modifiers, they only end in "end" when they begin a line on their
			# own.
			ends << false
			#puts "(#{ends.size}) #{line}"


		# DO (BLOCK)
		when /\bdo\b/
			# "do" always ends in end, so it can occur anywhere on a line.
			ends << false
			#puts "(#{ends.size}) #{line}"


		else
			# Any other line clears the comment
			comment = ""
		end
		
		# This is screwed.  We have to find ends that AREN'T in comments, and
		# AREN'T in strings.  This is a pain.
		if line =~ /\bend\b/
			line = line.scan( /(.*?)#.*/ )[0][0] if line =~ /#/
			matches = line.scan( /["'].*?\bend\b.*?["']?/ )#'
			matches.each { |x| line.sub! x,'' }
			line.scan(/(\bend\b)/).each { |e| stack.pop if ends.pop }
			#puts "(#{ends.size}) #{line}"
		end
	end
end

# This is a recursive method that finds the Ruby sources in a directory (and
# its sub-directories) and calls the grunt method "generate_api" on each.  It
# also generates indexes, and handles the generation of the directory
# hierarchy to match the source hierarchy.  Note that the directory recursion
# hasn't been tested.
def dodir path
	file_list = Dir.entries( path ).delete_if {|item| item !~ /\.r(uby|b)$/}
	subdir_list = Dir.entries( path ).delete_if {|item|
		item =~ /^\.+(svn)?$/ or !File.directory?(File.join(path, item))
	}
	unless file_list.empty?
		for file in file_list
			puts file
			prefix, suffix = file.split "."
			generate_api File.new(File.join path,file)
		end

		subdir = File.join("api", path)
		Dir.mkdir subdir unless File.exists? subdir
		@classes.each_pair{ |name, doc| 
			write path, name, doc
			@index.root.add_element("class", { "ref"=>"#{name}" })
		}
		@classes.clear
		@modules.each_pair{ |name, doc| 
			write path, name, doc
			@index.root.add_element("module", { "ref"=>"#{name}" })
		}
		@modules.clear
	end
	for subdir in subdir_list
		dodir File.join( path, subdir )
	end
end

def write path, name, doc
	# This was less useful than I thought it would be
	#minigraph = Graph.new
	#minigraph.add doc.root
	#minigraph.write_to File.join("api", path, "#{name}.png"), "png", "5,5"
	#doc.root.add_element "graph", {"img"=>"#{name}.png"}
	@graph.add doc.root if @graph
	fname = File.join("api", path, "#{name}.xml")
	fout = File.new(fname, "w+")
	doc.write fout
	fout.close
end

# This should be improved; the index is assumed to be a module (IE, it assumes
# that if you call "api2xml.rb rexml", then "rexml" is the module that
# contains your project documentation.  This is a cheap hack; I just reused
# the "module" XSL stylesheet, rather than writing a new one specifically for
# the index.  Ugly, but cheap and easy.  I'll fix it someday.  What it does
# mean is that you can't do something like "api2xml.rb ."
@index = Document.new
@index << DocType.new( "module" )
@index.add_element "module", {"name"=>ARGV[0]}

dodir ARGV[0] if File.directory? ARGV[0]
if @graph
	puts "Generating graphs."
	@index.root.add_element "graph", {"name"=>"graph.ps", "img"=>"graph.png"}
	@graph.write_to File.join("api", ARGV[0], "graph.ps")
	@graph.write_to File.join("api", ARGV[0], "graph.svg"), "svg"
	@graph.write_to File.join("api", ARGV[0], "graph.png"), "png", "9,11"
end
open( File.join("api", ARGV[0], "index.xml"), "w+" ) {|file| @index.write file}
open( File.join("api", ARGV[0], "OVERFLOW.xml"), "w+") {|f| @default.write f } if @default.root.elements.size > 0
