# MyoPyx
# A PYX document parser.
# Author: Sean Russell <ser@germane-software.com>
# Version: 1.0
# 
# Still to do:
# * Add support for listening for multiple tags with one
#   addListener() call.</li>
class MyoPyx
	attr_accessor :listeners, :pi_listeners

	def initialize
		@listeners = {}
		@pi_listeners = []
	end

	# Parses a Reader stream, generating events that are sent to
	# MyoPyx.Listeners that are added with addListener() or
	# addPIListener().
	# 
	#  p = MyoPyx.new()
	#  p.addListener( my_listener )
	#  input = File.new( "my.pyx" )
	#  p.parse( input )
	#
	# An exception is raised if there is a problem reading from the
	# reader, or if there is a parsing problem.  If there is a parsing
	# exception, such as bad markup or bad document formatting, the 
	# error message will contnain the line number where the error 
	# occurred.
	def parse( reader )
		tags = []
		current_tag = nil
		listeners = nil
		all_list = @listeners[ ' ' ]
		text = ""
		attributes = nil

		reader.each do |line|
			line.chop!
			text = line[1..-1] if line.size > 1
			next if line.strip.size == 0
			if line[0] != ?A and attributes
				if current_tag != nil
					all_list.each{|list| list.start_element(current_tag, attributes)} if all_list
					listeners.each{|list| list.start_element(current_tag, attributes)} if listeners
				end
				attributes = nil
			end

			case line[0]
			when ?(
				attributes = {}
				tags << current_tag if current_tag != nil
				current_tag = text
				listeners = @listeners[current_tag]
			when ?)
				raise "Parse error at line #$.\n"+
					"Expected end tag '#{current_tag}' but "+
					"got end tag '#{text}'\n"+
					"Line: '#{line}'\n" if text != current_tag
				all_list.each{|list| list.end_element(current_tag)} if all_list
				listeners.each{|list| list.end_element(current_tag)} if listeners
				listeners = nil
				current_tag = nil
				if tags.size > 0
					current_tag = tags.pop
					listeners = @listeners[ current_tag ]
				end
			when ?A
				text[/(.*?) (.*)/]
				attributes[$1] = $2
			when ?-
				all_list.each{|list| list.text text} if all_list
				listeners.each{|list| list.text text} if listeners
			when ??
				@pi_listeners.each{|list| list.pi text}
			else
				raise "Parse exception at line #$.\n"+
					"Bad markup\n"+
					"Line: '#{line}'\n"
			end
		end
	end

	# Add a listener for parsing events.  The listener must define 
	# the methods:
	#  * start_element( name, attrs ) # name isa String, attrs isa {}
	#  * end_element( name ) # name isa String
	#  * text( content ) # content isa String
	#
	#  class MyTagListener
	#    include MyoPyx::DummyListener
	#    def start_element( name, attrs )
	#      puts "MyTag has attributes:"
	#      attrs.each_pair { |key, value| puts "\t#{key} -> #{value}" }
	#  end
	#  class AllListener
	#    include MyoPyx::DummyListener
	#    def start_element( name, attrs )
	#      puts "Got tag #{name}"
	#    end
	#  end
	#  pyx.addListener( AllListener.new );
	#  pyx.addListener( MyTagListener.new, "MyTag" )
	#
	# Listeners for all events will be notified before listeners of
	# specific events.  After this, Listeners are notified in the
	# order they are added.
	#
	# If tag_name is null, the listener will be notified of all
	# events.  If it is not null, the listener will be notified of only
	# events pertaining to elements matching the tag_name.
	def add_listener listener, tag_name=nil
		tag_name = ' ' unless tag_name
		@listeners[ tag_name ] = [] unless @listeners[ tag_name ]
		@listeners[ tag_name ] << listener unless @listeners[ tag_name ].include? listener
	end

	# Removes a listener.  The tag must match the tag under which the
	# listener was added; for example, if a listener is added as both
	# a "MyTag1" and "MyTag2", then remove_listener(listener)
	# will do nothing, and remove_listener(listener, "MyTag1") will only
	# remove the listener for events on "MyTag1", not "MyTag2".
	def remove_listener listener, tag_name=nil
		tag_name = ' ' unless tag_name
		@listeners[ tag_name ].delete listener
	end

	# Attaches a listener of processing instruction (PI) events.
	# The listener must define a pi( text ) method, which will be called
	# when a processing instruction is encountered; the text will be the
	# body of the processing instruction
	def add_pi_listener listener
		@pi_listeners << listener unless @pi_listeners.include? listener
	end

	def remove_pi_listener listener
		@pi_listeners.delete listener
	end

	# You can "include" this module to stub out your listeners
	module DummyListener
		def start_element( name, attrs ) ; end
		def end_element( name ) ; end
		def text( content ) ; end
	end
end
