require 'json'
require 'tempfile'
require 'version_sorter'
require 'rocco'
require 'docurium/version'
require 'docurium/layout'
require 'docurium/debug'
require 'libdetect'
require 'docurium/docparser'
require 'pp'
require 'rugged'
require 'redcarpet'
require 'redcarpet/compat'
require 'parallel'
require 'thread'
# Markdown expects the old redcarpet compat API, so let's tell it what
# to use
Rocco::Markdown = RedcarpetCompat
class Docurium
attr_accessor :branch, :output_dir, :data, :head_data
def initialize(config_file, cli_options = {}, repo = nil)
raise "You need to specify a config file" if !config_file
raise "You need to specify a valid config file" if !valid_config(config_file)
@sigs = {}
@head_data = nil
@repo = repo || Rugged::Repository.discover(config_file)
@cli_options = cli_options
end
def init_data(version = 'HEAD')
data = {:files => [], :functions => {}, :callbacks => {}, :globals => {}, :types => {}, :prefix => ''}
data[:prefix] = option_version(version, 'input', '')
data
end
def option_version(version, option, default = nil)
if @options['legacy']
if valhash = @options['legacy'][option]
valhash.each do |value, versions|
return value if versions.include?(version)
end
end
end
opt = @options[option]
opt = default if !opt
opt
end
def format_examples!(data, version)
examples = []
if ex = option_version(version, 'examples')
if subtree = find_subtree(version, ex) # check that it exists
index = Rugged::Index.new
index.read_tree(subtree)
files = []
index.each do |entry|
next unless entry[:path].match(/\.c$/)
files << entry[:path]
end
files.each do |file|
# highlight, roccoize and link
rocco = Rocco.new(file, files, {:language => 'c'}) do
ientry = index[file]
blob = @repo.lookup(ientry[:oid])
blob.content
end
extlen = -(File.extname(file).length + 1)
rf_path = file[0..extlen] + '.html'
rel_path = "ex/#{version}/#{rf_path}"
rocco_layout = Rocco::Layout.new(rocco, @tf)
# find out how deep our file is so we can use the right
# number of ../ in the path
depth = rel_path.count('/') - 1
if depth == 0
rocco_layout[:dirsup] = "./"
else
rocco_layout[:dirsup] = "../"*depth
end
rocco_layout.version = version
rf = rocco_layout.render
# look for function names in the examples and link
id_num = 0
data[:functions].each do |f, fdata|
rf.gsub!(/#{f}([^\w])/) do |fmatch|
extra = $1
id_num += 1
name = f + '-' + id_num.to_s
# save data for cross-link
data[:functions][f][:examples] ||= {}
data[:functions][f][:examples][file] ||= []
data[:functions][f][:examples][file] << rel_path + '#' + name
"#{f}#{extra}"
end
end
# write example to the repo
sha = @repo.write(rf, :blob)
examples << [rel_path, sha]
data[:examples] ||= []
data[:examples] << [file, rel_path]
end
end
end
examples
end
def generate_doc_for(version)
index = Rugged::Index.new
read_subtree(index, version, option_version(version, 'input', ''))
data = parse_headers(index, version)
examples = format_examples!(data, version)
[data, examples]
end
def process_project(versions)
nversions = versions.count
Parallel.each_with_index(versions, finish: -> (version, index, result) do
data, examples = result
# There's still some work we need to do serially
tally_sigs!(version, data)
force_utf8(data)
puts "Adding documentation for #{version} [#{index}/#{nversions}]"
# Store it so we can show it at the end
@head_data = data if version == 'HEAD'
yield index, version, result if block_given?
end) do |version, index|
puts "Generating documentation for #{version} [#{index}/#{nversions}]"
generate_doc_for(version)
end
end
def generate_docs
output_index = Rugged::Index.new
write_site(output_index)
@tf = File.expand_path(File.join(File.dirname(__FILE__), 'docurium', 'layout.mustache'))
versions = get_versions
versions << 'HEAD'
# If the user specified versions, validate them and overwrite
if !(vers = (@cli_options[:for] || [])).empty?
vers.each do |v|
next if versions.include?(v)
puts "Unknown version #{v}"
exit(false)
end
versions = vers
end
if (@repo.config['user.name'].nil? || @repo.config['user.email'].nil?)
puts "ERROR: 'user.name' or 'user.email' is not configured. Docurium will not be able to commit the documentation"
exit(false)
end
process_project(versions) do |i, version, result|
data, examples = result
sha = @repo.write(data.to_json, :blob)
print "Generating documentation [#{i}/#{versions.count}]\r"
unless dry_run?
output_index.add(:path => "#{version}.json", :oid => sha, :mode => 0100644)
examples.each do |path, id|
output_index.add(:path => path, :oid => id, :mode => 0100644)
end
end
end
if head_data
puts ''
show_warnings(head_data)
end
return if dry_run?
# We tally the signatures in the order they finished, which is
# arbitrary due to the concurrency, so we need to sort them once
# they've finished.
sort_sigs!
project = {
:versions => versions.reverse,
:github => @options['github'],
:name => @options['name'],
:signatures => @sigs,
}
sha = @repo.write(project.to_json, :blob)
output_index.add(:path => "project.json", :oid => sha, :mode => 0100644)
css = File.read(File.expand_path(File.join(File.dirname(__FILE__), 'docurium', 'css.css')))
sha = @repo.write(css, :blob)
output_index.add(:path => "ex/css.css", :oid => sha, :mode => 0100644)
br = @options['branch']
out "* writing to branch #{br}"
refname = "refs/heads/#{br}"
tsha = output_index.write_tree(@repo)
puts "\twrote tree #{tsha}"
ref = @repo.references[refname]
user = { :name => @repo.config['user.name'], :email => @repo.config['user.email'], :time => Time.now }
options = {}
options[:tree] = tsha
options[:author] = user
options[:committer] = user
options[:message] = 'generated docs'
options[:parents] = ref ? [ref.target] : []
options[:update_ref] = refname
csha = Rugged::Commit.create(@repo, options)
puts "\twrote commit #{csha}"
puts "\tupdated #{br}"
end
def force_utf8(data)
# Walk the data to force strings encoding to UTF-8.
if data.instance_of? Hash
data.each do |key, value|
if [:comment, :comments, :description].include?(key)
data[key] = value.force_encoding('UTF-8') unless value.nil?
else
force_utf8(value)
end
end
elsif data.respond_to?(:each)
data.each { |x| force_utf8(x) }
end
end
class Warning
class UnmatchedParameter < Warning
def initialize(function, opts = {})
super :unmatched_param, :function, function, opts
end
def _message; "unmatched param"; end
end
class SignatureChanged < Warning
def initialize(function, opts = {})
super :signature_changed, :function, function, opts
end
def _message; "signature changed"; end
end
class MissingDocumentation < Warning
def initialize(type, identifier, opts = {})
super :missing_documentation, type, identifier, opts
end
def _message
["%s %s is missing documentation", :type, :identifier]
end
end
WARNINGS = [
:unmatched_param,
:signature_changed,
:missing_documentation,
]
attr_reader :warning, :type, :identifier, :file, :line, :column
def initialize(warning, type, identifier, opts = {})
raise ArgumentError.new("invalid warning class") unless WARNINGS.include?(warning)
@warning = warning
@type = type
@identifier = identifier
if type = opts.delete(:type)
@file = type[:file]
if input_dir = opts.delete(:input_dir)
File.expand_path(File.join(input_dir, @file))
end
@file ||= ""
@line = type[:line] || 1
@column = type[:column] || 1
end
end
def message
msg = self._message
msg.kind_of?(Array) ? msg.shift % msg.map {|a| self.send(a).to_s } : msg
end
end
def collect_warnings(data)
warnings = []
input_dir = File.join(@project_dir, option_version("HEAD", 'input'))
# check for unmatched paramaters
data[:functions].each do |f, fdata|
warnings << Warning::UnmatchedParameter.new(f, type: fdata, input_dir: input_dir) if fdata[:comments] =~ /@param/
end
# check for changed signatures
sigchanges = []
@sigs.each do |fun, sig_data|
warnings << Warning::SignatureChanged.new(fun) if sig_data[:changes]['HEAD']
end
# check for undocumented things
types = [:functions, :callbacks, :globals, :types]
types.each do |type_id|
under_type = type_id.tap {|t| break t.to_s[0..-2].to_sym }
data[type_id].each do |ident, type|
under_type = type[:type] if type_id == :types
warnings << Warning::MissingDocumentation.new(under_type, ident, type: type, input_dir: input_dir) if type[:description].empty?
case type[:type]
when :struct
if type[:fields]
type[:fields].each do |field|
warnings << Warning::MissingDocumentation.new(:field, "#{ident}.#{field[:name]}", type: type, input_dir: input_dir) if field[:comments].empty?
end
end
end
end
end
warnings
end
def check_warnings(options)
versions = []
versions << get_versions.pop
versions << 'HEAD'
process_project(versions)
collect_warnings(head_data).each do |warning|
puts "#{warning.file}:#{warning.line}:#{warning.column}: #{warning.message}"
end
end
def show_warnings(data)
out '* checking your api'
collect_warnings(data).group_by {|w| w.warning }.each do |klass, klass_warnings|
klass_warnings.group_by {|w| w.type }.each do |type, type_warnings|
out " - " + type_warnings[0].message
type_warnings.sort_by {|w| w.identifier }.each do |warning|
out "\t" + warning.identifier
end
end
end
end
def get_versions
releases = @repo.tags
.map { |tag| tag.name.gsub(%r(^refs/tags/), '') }
.delete_if { |tagname| tagname.match(%r(-rc\d*$)) }
VersionSorter.sort(releases)
end
def parse_headers(index, version)
headers = index.map { |e| e[:path] }.grep(/\.h$/)
files = headers.map do |file|
[file, @repo.lookup(index[file][:oid]).content]
end
data = init_data(version)
DocParser.with_files(files, :prefix => version) do |parser|
headers.each do |header|
records = parser.parse_file(header, debug: interesting?(:file, header))
update_globals!(data, records)
end
end
data[:groups] = group_functions!(data)
data[:types] = data[:types].sort # make it an assoc array
find_type_usage!(data)
data
end
private
def tally_sigs!(version, data)
@lastsigs ||= {}
data[:functions].each do |fun_name, fun_data|
if !@sigs[fun_name]
@sigs[fun_name] ||= {:exists => [], :changes => {}}
else
if @lastsigs[fun_name] != fun_data[:sig]
@sigs[fun_name][:changes][version] = true
end
end
@sigs[fun_name][:exists] << version
@lastsigs[fun_name] = fun_data[:sig]
end
end
def sort_sigs!
@sigs.keys.each do |fn|
VersionSorter.sort!(@sigs[fn][:exists])
# Put HEAD at the back
@sigs[fn][:exists] << @sigs[fn][:exists].shift
end
end
def find_subtree(version, path)
tree = nil
if version == 'HEAD'
tree = @repo.head.target.tree
else
trg = @repo.references["refs/tags/#{version}"].target
if(trg.kind_of? Rugged::Tag::Annotation)
trg = trg.target
end
tree = trg.tree
end
begin
tree_entry = tree.path(path)
@repo.lookup(tree_entry[:oid])
rescue Rugged::TreeError
nil
end
end
def read_subtree(index, version, path)
tree = find_subtree(version, path)
index.read_tree(tree)
end
def valid_config(file)
return false if !File.file?(file)
fpath = File.expand_path(file)
@project_dir = File.dirname(fpath)
@config_file = File.basename(fpath)
@options = JSON.parse(File.read(fpath))
!!@options['branch']
end
def group_functions!(data)
func = {}
data[:functions].each_pair do |key, value|
debug_set interesting?(:function, key)
debug "grouping #{key}: #{value}"
if @options['prefix']
k = key.gsub(@options['prefix'], '')
else
k = key
end
group, rest = k.split('_', 2)
debug "grouped: k: #{k}, group: #{group}, rest: #{rest}"
if group.empty?
puts "empty group for function #{key}"
next
end
debug "grouped: k: #{k}, group: #{group}, rest: #{rest}"
data[:functions][key][:group] = group
func[group] ||= []
func[group] << key
func[group].sort!
end
func.to_a.sort
end
def find_type_usage!(data)
# go through all functions, callbacks, and structs
# see which other types are used and returned
# store them in the types data
h = {}
h.merge!(data[:functions])
h.merge!(data[:callbacks])
structs = data[:types].find_all {|t, tdata| (tdata[:type] == :struct and tdata[:fields] and not tdata[:fields].empty?) }
structs = Hash[structs.map {|t, tdata| [t, tdata] }]
h.merge!(structs)
h.each do |use, use_data|
data[:types].each_with_index do |tdata, i|
type, typeData = tdata
data[:types][i][1][:used] ||= {:returns => [], :needs => [], :fields => []}
if use_data[:return] && use_data[:return][:type].index(/#{type}[ ;\)\*]?/)
data[:types][i][1][:used][:returns] << use
data[:types][i][1][:used][:returns].sort!
end
if use_data[:argline] && use_data[:argline].index(/#{type}[ ;\)\*]?/)
data[:types][i][1][:used][:needs] << use
data[:types][i][1][:used][:needs].sort!
end
if use_data[:fields] and use_data[:fields].find {|f| f[:type] == type }
data[:types][i][1][:used][:fields] << use
data[:types][i][1][:used][:fields].sort!
end
end
end
end
def update_globals!(data, recs)
return if recs.empty?
wanted = {
:functions => %W/type value file line lineto args argline sig return group description comments/.map(&:to_sym),
:types => %W/decl type value file line lineto block tdef description comments fields/.map(&:to_sym),
:globals => %W/value file line comments/.map(&:to_sym),
:meta => %W/brief defgroup ingroup comments/.map(&:to_sym),
}
file_map = {}
md = Redcarpet::Markdown.new(Redcarpet::Render::HTML.new({}), :no_intra_emphasis => true)
recs.each do |r|
types = %w(function file type).map(&:to_sym)
dbg = false
types.each do |t|
dbg ||= if r[:type] == t and interesting?(t, r[:name])
true
elsif t == :file and interesting?(:file, r[:file])
true
elsif [:struct, :enum].include?(r[:type]) and interesting?(:type, r[:name])
true
else
false
end
end
debug_set dbg
debug "processing record: #{r}"
debug
# initialize filemap for this file
file_map[r[:file]] ||= {
:file => r[:file], :functions => [], :meta => {}, :lines => 0
}
if file_map[r[:file]][:lines] < r[:lineto]
file_map[r[:file]][:lines] = r[:lineto]
end
# process this type of record
case r[:type]
when :function, :callback
t = r[:type] == :function ? :functions : :callbacks
data[t][r[:name]] ||= {}
wanted[:functions].each do |k|
next unless r.has_key? k
if k == :description || k == :comments
contents = md.render r[k]
else
contents = r[k]
end
data[t][r[:name]][k] = contents
end
file_map[r[:file]][:functions] << r[:name]
when :define, :macro
data[:globals][r[:decl]] ||= {}
wanted[:globals].each do |k|
next unless r.has_key? k
if k == :description || k == :comments
data[:globals][r[:decl]][k] = md.render r[k]
else
data[:globals][r[:decl]][k] = r[k]
end
end
when :file
wanted[:meta].each do |k|
file_map[r[:file]][:meta][k] = r[k] if r.has_key?(k)
end
when :enum
if !r[:name]
# Explode unnamed enum into multiple global defines
r[:decl].each do |n|
data[:globals][n] ||= {
:file => r[:file], :line => r[:line],
:value => "", :comments => md.render(r[:comments]),
}
m = /#{Regexp.quote(n)}/.match(r[:body])
if m
data[:globals][n][:line] += m.pre_match.scan("\n").length
if m.post_match =~ /\s*=\s*([^,\}]+)/
data[:globals][n][:value] = $1
end
end
end
else # enum has name
data[:types][r[:name]] ||= {}
wanted[:types].each do |k|
next unless r.has_key? k
contents = r[k]
if k == :comments
contents = md.render r[k]
elsif k == :block
old_block = data[:types][r[:name]][k]
contents = old_block ? [old_block, r[k]].join("\n") : r[k]
elsif k == :fields
type = data[:types][r[:name]]
type[:fields] = []
r[:fields].each do |f|
f[:comments] = md.render(f[:comments])
end
end
data[:types][r[:name]][k] = contents
end
end
when :struct, :fnptr
data[:types][r[:name]] ||= {}
known = data[:types][r[:name]]
r[:value] ||= r[:name]
# we don't want to override "opaque" structs with typedefs or
# "public" documentation
unless r[:tdef].nil? and known[:fields] and known[:comments] and known[:description]
wanted[:types].each do |k|
next unless r.has_key? k
if k == :comments
data[:types][r[:name]][k] = md.render r[k]
else
data[:types][r[:name]][k] = r[k]
end
end
else
# We're about to skip that type. Just make sure we preserve the
# :fields comment
if r[:fields] and known[:fields].empty?
data[:types][r[:name]][:fields] = r[:fields]
end
end
if r[:type] == :fnptr
data[:types][r[:name]][:type] = "function pointer"
end
else
# Anything else we want to record?
end
debug "processed record: #{r}"
debug
debug_restore
end
data[:files] << file_map.values[0]
end
def add_dir_to_index(index, prefix, dir)
Dir.new(dir).each do |filename|
next if [".", ".."].include? filename
name = File.join(dir, filename)
if File.directory? name
add_dir_to_index(index, prefix, name)
else
rel_path = name.gsub(prefix, '')
content = File.read(name)
sha = @repo.write(content, :blob)
index.add(:path => rel_path, :oid => sha, :mode => 0100644)
end
end
end
def write_site(index)
here = File.expand_path(File.dirname(__FILE__))
dirname = File.join(here, '..', 'site')
dirname = File.realpath(dirname)
add_dir_to_index(index, dirname + '/', dirname)
end
def out(text)
puts text
end
def dry_run?
@cli_options[:dry_run]
end
def interesting?(type, what)
@cli_options['debug'] || (@cli_options["debug-#{type}"] || []).include?(what)
end
end