#!/usr/bin/python
#
#  Combine all source and header files in source directory into
#  a single C file.
#
#  The process is not very simple or clean.  This helper is not
#  a generic or 100% correct in the general case: it just needs
#  to work for Duktape.
#
#  Overview of the process:
#
#    * Parse all relevant C and H files.
#
#    * Change all non-exposed functions and variables to "static" in
#      both headers (extern -> static) and in implementation files.
#
#    * Emit internal headers by starting from duk_internal.h (the only
#      internal header included by Duktape C files) and emulating the
#      include mechanism recursively.
#
#    * Emit all source files, removing any internal includes (these
#      should all be duk_internal.h ideally but there are a few remnants).
#      
#  At every step, source and header lines are represented with explicit
#  line objects which keep track of original filename and line.  The
#  output contains #line directives, if necessary, to ensure error
#  throwing and other diagnostic info will work in a useful manner when
#  deployed.
#
#  Making the process deterministic is important, so that if users have
#  diffs that they apply to the combined source, such diffs would apply
#  for as long as possible.
#
#  Limitations and notes:
#
#    * #defines are not #undef'd at the end of an input file, so defines
#      may bleed to other files.  These need to be fixed in the original
#      sources.
#
#    * System headers included with a certain define (like _BSD_SOURCE)
#      are not handled correctly now.
#
#    * External includes are not removed or combined: some of them are
#      inside #ifdef directives, so it would difficult to do so.  Ideally
#      there would be no external includes in individual files.

import os
import sys
import re

re_extinc = re.compile(r'^#include <(.*?)>.*$')
re_intinc = re.compile(r'^#include \"(duk.*?)\".*$')  # accept duktape.h too

class File:
	filename_full = None
	filename = None
	lines = None

	def __init__(self, filename, lines):
		self.filename = os.path.basename(filename)
		self.filename_full = filename
		self.lines = lines

class Line:
	filename_full = None
	filename = None
	lineno = None
	data = None

	def __init__(self, filename, lineno, data):
		self.filename = os.path.basename(filename)
		self.filename_full = filename
		self.lineno = lineno
		self.data = data

def read(filename):
	lines = []

	f = None
	try:
		f = open(filename, 'rb')
		lineno = 0
		for line in f:
			lineno += 1
			if len(line) > 0 and line[-1] == '\n':
				line = line[:-1]
			lines.append(Line(filename, lineno, line))
	finally:
		if f is not None:
			f.close()

	return File(filename, lines)

def findFile(files, filename):
	for i in files:
		if i.filename == filename:
			return i
	return None

def processIncludes(f):
	extinc = []
	intinc = []

	for line in f.lines:
		if not line.data.startswith('#include'):
			continue

		m = re_extinc.match(line.data)
		if m is not None:
			# external includes are kept; they may even be conditional
			extinc.append(m.group(1))
			#line.data = ''
			continue

		m = re_intinc.match(line.data)
		if m is not None:
			intinc.append(m.group(1))
			#line.data = ''
			continue

		print(line.data)
		raise Exception('cannot parse include directive')

	return extinc, intinc

def processDeclarations(f):
	for line in f.lines:
		# FIXME: total placeholder
		if line.data.startswith('int ') or line.data.startswith('void '):
			line.data = 'static ' + line.data
		elif line.data.startswith('extern int') or line.data.startswith('extern void '):
			line.data = 'static ' + line.data[7:]  # replace extern with static

def createCombined(files, extinc, intinc):
	res = []

	emit_state = [ None, None ]  # curr_filename, curr_lineno

	def emit(line):
		if isinstance(line, (str, unicode)):
			res.append(line)
			emit_state[1] += 1
		else:
			if line.filename != emit_state[0] or line.lineno != emit_state[1]:
				res.append('#line %d "%s"' % (line.lineno, line.filename))
			res.append(line.data)
			emit_state[0] = line.filename
			emit_state[1] = line.lineno + 1

	processed = {}

	# Helper to process internal headers recursively, starting from duk_internal.h
	def processHeader(f_hdr):
		#print('Process header: ' + f_hdr.filename)
		for line in f_hdr.lines:
			m = re_intinc.match(line.data)
			if m is None:
				emit(line)
				continue
			incname = m.group(1)
			if incname in [ 'duktape.h', 'duk_custom.h' ]:
				# keep a few special headers as is
				emit(line)
				continue

			#print('Include: ' + incname)
			f_inc = findFile(files, incname)
			assert(f_inc)

			if processed.has_key(f_inc.filename):
				#print('already included, skip: ' + f_inc.filename)
				emit('/* already included: %s */' % f_inc.filename)
				continue
			processed[f_inc.filename] = True

			# include file in this place, recursively
			processHeader(f_inc)

	# Process internal headers by starting with duk_internal.h
	f_dukint = findFile(files, 'duk_internal.h')
	assert(f_dukint)
	processHeader(f_dukint)

	# Mark all internal headers processed
	for f in files:
		if os.path.splitext(f.filename)[1] != '.h':
			continue
		processed[f.filename] = True

	# Then emit remaining files
	for f in files:
		if processed.has_key(f.filename):
			continue

		for line in f.lines:
			m = re_intinc.match(line.data)
			if m is None:
				emit(line)
			else:
				incname = m.group(1)
				emit('/* include removed: %s */' % incname)

	return '\n'.join(res) + '\n'

def main():
	outname = sys.argv[2]
	assert(outname)

	print 'Read input files'
	files = []
	filelist = os.listdir(sys.argv[1])
	filelist.sort()  # for consistency
	for fn in filelist:
		if os.path.splitext(fn)[1] not in [ '.c', '.h' ]:
			continue
		res = read(os.path.join(sys.argv[1], fn))
		files.append(res)
	print '%d files read' % len(files)

	print 'Process #include statements'
	extinc = []
	intinc = []
	for f in files:
		extnew, intnew = processIncludes(f)
		for i in extnew:
			if i in extinc:
				continue
			extinc.append(i)
		for i in intnew:
			if i in intinc:
				continue
			intinc.append(i)

	#print('external includes: ' + ', '.join(extinc))
	#print('internal includes: ' + ', '.join(intinc))

	print 'Process declarations (non-exposed are converted to static)'
	for f in files:
		#processDeclarations(f)
		pass

	print 'Output final file'
	final = createCombined(files, extinc, intinc)
	f = open(outname, 'wb')
	f.write(final)
	f.close()

	print 'Wrote %d bytes to %s' % (len(final), outname)