#!/usr/bin/env python

# Copyright (c) 2012-2014 by the GalSim developers team on GitHub
# https://github.com/GalSim-developers
#
# This file is part of GalSim: The modular galaxy image simulation toolkit.
# https://github.com/GalSim-developers/GalSim
#
# GalSim is free software: redistribution and use in source and binary forms,
# with or without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions, and the disclaimer given in the accompanying LICENSE
#    file.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions, and the disclaimer given in the documentation
#    and/or other materials provided with the distribution.
#

__applicationName__ = "doxypy"
__blurb__ = """
doxypy is an input filter for Doxygen. It preprocesses python
files so that docstrings of classes and functions are reformatted
into Doxygen-conform documentation blocks.
"""

__doc__ = __blurb__ + \
"""
In order to make Doxygen preprocess files through doxypy, simply
add the following lines to your Doxyfile:
	FILTER_SOURCE_FILES = YES
	INPUT_FILTER = "python /path/to/doxypy.py"
"""

__version__ = "0.4.2"
__date__ = "14th October 2009"
__website__ = "http://code.foosel.org/doxypy"

__author__ = (
	"Philippe 'demod' Neumann (doxypy at demod dot org)",
	"Gina 'foosel' Haeussge (gina at foosel dot net)" 
)

__licenseName__ = "BSD"
__license__ = """
Copyright (c) 2012-2014 by the GalSim developers team on GitHub
https://github.com/GalSim-developers

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
   list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

This software is made available to you on an ``as is'' basis with no
representations or warranties, express or implied, including but not
limited to any warranty of performance, merchantability, fitness for a
particular purpose, commercial utility, non-infringement or title.
Neither the authors nor the organizations providing the support under
which the work was developed will be liable to you or any third party
with respect to any claim arising from your further development of the
software or any products related to or derived from the software, or for
lost profits, business interruption, or indirect special or consequential
damages of any kind.
"""

import sys
import re

from optparse import OptionParser, OptionGroup

class FSM(object):
	"""Implements a finite state machine.
	
	Transitions are given as 4-tuples, consisting of an origin state, a target
	state, a condition for the transition (given as a reference to a function
	which gets called with a given piece of input) and a pointer to a function
	to be called upon the execution of the given transition. 
	"""
	
	"""
	@var transitions holds the transitions
	@var current_state holds the current state
	@var current_input holds the current input
	@var current_transition hold the currently active transition
	"""
	
	def __init__(self, start_state=None, transitions=[]):
		self.transitions = transitions
		self.current_state = start_state
		self.current_input = None
		self.current_transition = None
		
	def setStartState(self, state):
		self.current_state = state

	def addTransition(self, from_state, to_state, condition, callback):
		self.transitions.append([from_state, to_state, condition, callback])
		
	def makeTransition(self, input):
		"""Makes a transition based on the given input.
		
		@param	input	input to parse by the FSM
		"""
		for transition in self.transitions:
			[from_state, to_state, condition, callback] = transition
			if from_state == self.current_state:
				match = condition(input)
				if match:
					self.current_state = to_state
					self.current_input = input
					self.current_transition = transition
					if options.debug:
						print >>sys.stderr, "# FSM: executing (%s -> %s) for line '%s'" % (from_state, to_state, input)
					callback(match)
					return

class Doxypy(object):
	def __init__(self):
		string_prefixes = "[uU]?[rR]?"
		
		self.start_single_comment_re = re.compile("^\s*%s(''')" % string_prefixes)
		self.end_single_comment_re = re.compile("(''')\s*$")
		
		self.start_double_comment_re = re.compile("^\s*%s(\"\"\")" % string_prefixes)
		self.end_double_comment_re = re.compile("(\"\"\")\s*$")
		
		self.single_comment_re = re.compile("^\s*%s(''').*(''')\s*$" % string_prefixes)
		self.double_comment_re = re.compile("^\s*%s(\"\"\").*(\"\"\")\s*$" % string_prefixes)
		
		self.defclass_re = re.compile("^(\s*)(def .+:|class .+:)")
		self.empty_re = re.compile("^\s*$")
		self.hashline_re = re.compile("^\s*#.*$")
		self.importline_re = re.compile("^\s*(import |from .+ import)")

		self.multiline_defclass_start_re = re.compile("^(\s*)(def|class)(\s.*)?$")
		self.multiline_defclass_end_re = re.compile(":\s*$")
		
		## Transition list format
		#  ["FROM", "TO", condition, action]
		transitions = [
			### FILEHEAD
			
			# single line comments
			["FILEHEAD", "FILEHEAD", self.single_comment_re.search, self.appendCommentLine],
			["FILEHEAD", "FILEHEAD", self.double_comment_re.search, self.appendCommentLine],
			
			# multiline comments
			["FILEHEAD", "FILEHEAD_COMMENT_SINGLE", self.start_single_comment_re.search, self.appendCommentLine],
			["FILEHEAD_COMMENT_SINGLE", "FILEHEAD", self.end_single_comment_re.search, self.appendCommentLine],
			["FILEHEAD_COMMENT_SINGLE", "FILEHEAD_COMMENT_SINGLE", self.catchall, self.appendCommentLine],
			["FILEHEAD", "FILEHEAD_COMMENT_DOUBLE", self.start_double_comment_re.search, self.appendCommentLine],
			["FILEHEAD_COMMENT_DOUBLE", "FILEHEAD", self.end_double_comment_re.search, self.appendCommentLine],
			["FILEHEAD_COMMENT_DOUBLE", "FILEHEAD_COMMENT_DOUBLE", self.catchall, self.appendCommentLine],
			
			# other lines
			["FILEHEAD", "FILEHEAD", self.empty_re.search, self.appendFileheadLine],
			["FILEHEAD", "FILEHEAD", self.hashline_re.search, self.appendFileheadLine],
			["FILEHEAD", "FILEHEAD", self.importline_re.search, self.appendFileheadLine],
			["FILEHEAD", "DEFCLASS", self.defclass_re.search, self.resetCommentSearch],
			["FILEHEAD", "DEFCLASS_MULTI", self.multiline_defclass_start_re.search, self.resetCommentSearch],			
			["FILEHEAD", "DEFCLASS_BODY", self.catchall, self.appendFileheadLine],

			### DEFCLASS
			
			# single line comments
			["DEFCLASS", "DEFCLASS_BODY", self.single_comment_re.search, self.appendCommentLine],
			["DEFCLASS", "DEFCLASS_BODY", self.double_comment_re.search, self.appendCommentLine],
			
			# multiline comments
			["DEFCLASS", "COMMENT_SINGLE", self.start_single_comment_re.search, self.appendCommentLine],
			["COMMENT_SINGLE", "DEFCLASS_BODY", self.end_single_comment_re.search, self.appendCommentLine],
			["COMMENT_SINGLE", "COMMENT_SINGLE", self.catchall, self.appendCommentLine],
			["DEFCLASS", "COMMENT_DOUBLE", self.start_double_comment_re.search, self.appendCommentLine],
			["COMMENT_DOUBLE", "DEFCLASS_BODY", self.end_double_comment_re.search, self.appendCommentLine],
			["COMMENT_DOUBLE", "COMMENT_DOUBLE", self.catchall, self.appendCommentLine],

			# other lines
			["DEFCLASS", "DEFCLASS", self.empty_re.search, self.appendDefclassLine],
			["DEFCLASS", "DEFCLASS", self.defclass_re.search, self.resetCommentSearch],
			["DEFCLASS", "DEFCLASS_MULTI", self.multiline_defclass_start_re.search, self.resetCommentSearch],
			["DEFCLASS", "DEFCLASS_BODY", self.catchall, self.stopCommentSearch],
			
			### DEFCLASS_BODY
			
			["DEFCLASS_BODY", "DEFCLASS", self.defclass_re.search, self.startCommentSearch],
			["DEFCLASS_BODY", "DEFCLASS_MULTI", self.multiline_defclass_start_re.search, self.startCommentSearch],
			["DEFCLASS_BODY", "DEFCLASS_BODY", self.catchall, self.appendNormalLine],

			### DEFCLASS_MULTI
			["DEFCLASS_MULTI", "DEFCLASS", self.multiline_defclass_end_re.search, self.appendDefclassLine],
			["DEFCLASS_MULTI", "DEFCLASS_MULTI", self.catchall, self.appendDefclassLine],
		]
		
		self.fsm = FSM("FILEHEAD", transitions)
		self.outstream = sys.stdout
		
		self.output = []
		self.comment = []
		self.filehead = []
		self.defclass = []
		self.indent = ""

	def __closeComment(self):
		"""Appends any open comment block and triggering block to the output."""
		
		if options.autobrief:
			if len(self.comment) == 1 \
			or (len(self.comment) > 2 and self.comment[1].strip() == ''):
				self.comment[0] = self.__docstringSummaryToBrief(self.comment[0])
			
		if self.comment:
			block = self.makeCommentBlock()
			self.output.extend(block)
			
		if self.defclass:
			self.output.extend(self.defclass)

	def __docstringSummaryToBrief(self, line):
		"""Adds \\brief to the docstrings summary line.
		
		A \\brief is prepended, provided no other doxygen command is at the
		start of the line.
		"""
		stripped = line.strip()
		if stripped and not stripped[0] in ('@', '\\'):
			return "\\brief " + line
		else:
			return line
	
	def __flushBuffer(self):
		"""Flushes the current outputbuffer to the outstream."""
		if self.output:
			try:
				if options.debug:
					print >>sys.stderr, "# OUTPUT: ", self.output
				print >>self.outstream, "\n".join(self.output)
				self.outstream.flush()
			except IOError:
				# Fix for FS#33. Catches "broken pipe" when doxygen closes 
				# stdout prematurely upon usage of INPUT_FILTER, INLINE_SOURCES 
				# and FILTER_SOURCE_FILES.
				pass
		self.output = []

	def catchall(self, input):
		"""The catchall-condition, always returns true."""
		return True
	
	def resetCommentSearch(self, match):
		"""Restarts a new comment search for a different triggering line.
		
		Closes the current commentblock and starts a new comment search.
		"""
		if options.debug:
			print >>sys.stderr, "# CALLBACK: resetCommentSearch" 
		self.__closeComment()
		self.startCommentSearch(match)
	
	def startCommentSearch(self, match):
		"""Starts a new comment search.
		
		Saves the triggering line, resets the current comment and saves
		the current indentation.
		"""
		if options.debug:
			print >>sys.stderr, "# CALLBACK: startCommentSearch"
		self.defclass = [self.fsm.current_input]
		self.comment = []
		self.indent = match.group(1)
	
	def stopCommentSearch(self, match):
		"""Stops a comment search.
		
		Closes the current commentblock, resets	the triggering line and
		appends the current line to the output.
		"""
		if options.debug:
			print >>sys.stderr, "# CALLBACK: stopCommentSearch" 
		self.__closeComment()
		
		self.defclass = []
		self.output.append(self.fsm.current_input)
	
	def appendFileheadLine(self, match):
		"""Appends a line in the FILEHEAD state.
		
		Closes the open comment	block, resets it and appends the current line.
		""" 
		if options.debug:
			print >>sys.stderr, "# CALLBACK: appendFileheadLine" 
		self.__closeComment()
		self.comment = []
		self.output.append(self.fsm.current_input)

	def appendCommentLine(self, match):
		"""Appends a comment line.
		
		The comment delimiter is removed from multiline start and ends as
		well as singleline comments.
		"""
		if options.debug:
			print >>sys.stderr, "# CALLBACK: appendCommentLine" 
		(from_state, to_state, condition, callback) = self.fsm.current_transition
		
		# single line comment
		if (from_state == "DEFCLASS" and to_state == "DEFCLASS_BODY") \
		or (from_state == "FILEHEAD" and to_state == "FILEHEAD"):
			# remove comment delimiter from begin and end of the line
			activeCommentDelim = match.group(1)
			line = self.fsm.current_input
			self.comment.append(line[line.find(activeCommentDelim)+len(activeCommentDelim):line.rfind(activeCommentDelim)])

			if (to_state == "DEFCLASS_BODY"):
				self.__closeComment()
				self.defclass = []
		# multiline start
		elif from_state == "DEFCLASS" or from_state == "FILEHEAD":
			# remove comment delimiter from begin of the line
			activeCommentDelim = match.group(1)
			line = self.fsm.current_input
			self.comment.append(line[line.find(activeCommentDelim)+len(activeCommentDelim):])
		# multiline end
		elif to_state == "DEFCLASS_BODY" or to_state == "FILEHEAD":
			# remove comment delimiter from end of the line
			activeCommentDelim = match.group(1)
			line = self.fsm.current_input
			self.comment.append(line[0:line.rfind(activeCommentDelim)])
			if (to_state == "DEFCLASS_BODY"):
				self.__closeComment()
				self.defclass = []
		# in multiline comment
		else:
			# just append the comment line
			self.comment.append(self.fsm.current_input)
	
	def appendNormalLine(self, match):
		"""Appends a line to the output."""
		if options.debug:
			print >>sys.stderr, "# CALLBACK: appendNormalLine" 
		self.output.append(self.fsm.current_input)
		
	def appendDefclassLine(self, match):
		"""Appends a line to the triggering block."""
		if options.debug:
			print >>sys.stderr, "# CALLBACK: appendDefclassLine" 
		self.defclass.append(self.fsm.current_input)
	
	def makeCommentBlock(self):
		"""Indents the current comment block with respect to the current
		indentation level.

		@returns a list of indented comment lines
		"""
		doxyStart = "##"
		commentLines = self.comment
		
		commentLines = map(lambda x: "%s# %s" % (self.indent, x), commentLines)
		l = [self.indent + doxyStart]
		l.extend(commentLines)
			 
		return l
	
	def parse(self, input):
		"""Parses a python file given as input string and returns the doxygen-
		compatible representation.
		
		@param	input	the python code to parse
		@returns the modified python code
		""" 
		lines = input.split("\n")
		
		for line in lines:
			self.fsm.makeTransition(line)
			
		if self.fsm.current_state == "DEFCLASS":
			self.__closeComment()
		
		return "\n".join(self.output)
	
	def parseFile(self, filename):
		"""Parses a python file given as input string and returns the doxygen-
		compatible representation.
		
		@param	input	the python code to parse
		@returns the modified python code
		""" 
		f = open(filename, 'r')
		
		for line in f:
			self.parseLine(line.rstrip('\r\n'))
		if self.fsm.current_state == "DEFCLASS":
			self.__closeComment()
			self.__flushBuffer()
		f.close()
	
	def parseLine(self, line):
		"""Parse one line of python and flush the resulting output to the 
		outstream.
		
		@param	line	the python code line to parse
		"""
		self.fsm.makeTransition(line)
		self.__flushBuffer()
	
def optParse():
	"""Parses commandline options."""
	parser = OptionParser(prog=__applicationName__, version="%prog " + __version__)
	
	parser.set_usage("%prog [options] filename")
	parser.add_option("--autobrief",
		action="store_true", dest="autobrief",
		help="use the docstring summary line as \\brief description"
	)
	parser.add_option("--debug",
		action="store_true", dest="debug",
		help="enable debug output on stderr"
	)
	
	## parse options
	global options
	(options, filename) = parser.parse_args()
	
	if not filename:
		print >>sys.stderr, "No filename given."
		sys.exit(-1)
	
	return filename[0]

def main():
	"""Starts the parser on the file given by the filename as the first 
	argument on the commandline.
	"""
	filename = optParse()
	fsm = Doxypy()
	fsm.parseFile(filename)

if __name__ == "__main__":
	main()
