blob: 408cd8eb64c698345522f2563611e29b260fee53 [file] [log] [blame]
# Copyright 2016 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Documentation generator for Skylark"""
from __future__ import print_function
# internal imports
import jinja2
import mistune
import os
import posixpath
import re
import shutil
import sys
import tempfile
import zipfile
from optparse import OptionParser
from bazel_tools.tools.python.runfiles import runfiles as runfiles_lib
from skydoc import common
from skydoc import load_extractor
from skydoc import macro_extractor
from skydoc import rule
from skydoc import rule_extractor
DEFAULT_OUTPUT_DIR = '.'
DEFAULT_OUTPUT_FILE = 'skydoc.zip'
WORKSPACE_DIR = 'io_bazel_skydoc'
TEMPLATE_PATH = 'skydoc/templates'
CSS_PATH = 'skydoc/sass'
CSS_FILE = 'main.css'
def _create_jinja_environment(runfiles, site_root, link_ext):
def _Load(path):
loc = runfiles.Rlocation(posixpath.join(WORKSPACE_DIR, TEMPLATE_PATH, path))
if loc:
with open(loc, "rb") as f:
return f.read().decode("utf-8")
return None
env = jinja2.Environment(
loader=jinja2.FunctionLoader(_Load),
keep_trailing_newline=True,
line_statement_prefix='%')
env.filters['markdown'] = lambda text: jinja2.Markup(mistune.markdown(text))
env.filters['doc_link'] = (
lambda fname: site_root + '/' + fname + '.' + link_ext)
env.filters['link'] = lambda fname: site_root + '/' + fname
return env
# TODO(dzc): Remove this workaround once we switch to a self-contained Python
# binary format such as PEX.
def _runfile_path(runfiles, path):
return runfiles.Rlocation(posixpath.join(WORKSPACE_DIR, path))
def merge_languages(macro_language, rule_language):
for rule in rule_language.rule:
new_rule = macro_language.rule.add()
new_rule.CopyFrom(rule)
return macro_language
class WriterOptions(object):
def __init__(self, output_dir, output_file, output_zip, overview,
overview_filename, link_ext, site_root):
self.output_dir = output_dir
self.output_file = output_file
self.output_zip = output_zip
self.overview = overview
self.overview_filename = overview_filename
self.link_ext = link_ext
self.site_root = site_root
if len(self.site_root) > 0 and self.site_root.endswith('/'):
self.site_root = self.site_root[:-1]
class MarkdownWriter(object):
"""Writer for generating documentation in Markdown."""
def __init__(self, writer_options, runfiles):
self.__options = writer_options
self.__env = _create_jinja_environment(runfiles,
self.__options.site_root,
self.__options.link_ext)
def write(self, rulesets):
"""Write the documentation for the rules contained in rulesets."""
try:
temp_dir = tempfile.mkdtemp()
output_files = []
for ruleset in rulesets:
if not ruleset.empty():
output_files.append(self._write_ruleset(temp_dir, ruleset))
if self.__options.overview:
output_files.append(self._write_overview(temp_dir, rulesets))
if self.__options.output_zip:
# We are generating a zip archive containing all the documentation.
# Write each documentation file generated in the temp directory to the
# zip file.
with zipfile.ZipFile(self.__options.output_file, 'w') as zf:
for output_file, output_path in output_files:
zf.write(output_file, output_path)
else:
# We are generating documentation in the output_dir directory. Copy each
# documentation file to output_dir.
for output_file, output_path in output_files:
dest_file = os.path.join(self.__options.output_dir, output_path)
dest_dir = os.path.dirname(dest_file)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
shutil.copyfile(output_file, dest_file)
finally:
# Delete temporary directory.
shutil.rmtree(temp_dir)
def _write_ruleset(self, output_dir, ruleset):
# Load template and render Markdown.
template = self.__env.get_template('markdown.jinja')
out = template.render(ruleset=ruleset)
# Write output to file. Output files are created in a directory structure
# that matches that of the input file.
output_path = ruleset.output_file + '.md'
output_file = "%s/%s" % (output_dir, output_path)
file_dirname = os.path.dirname(output_file)
if not os.path.exists(file_dirname):
os.makedirs(file_dirname)
with open(output_file, "w") as f:
f.write(out)
return (output_file, output_path)
def _write_overview(self, output_dir, rulesets):
template = self.__env.get_template('markdown_overview.jinja')
out = template.render(rulesets=rulesets)
output_file = "%s/%s.md" % (output_dir, self.__options.overview_filename)
with open(output_file, "w") as f:
f.write(out)
return (output_file, "%s.md" % self.__options.overview_filename)
class HtmlWriter(object):
"""Writer for generating documentation in HTML."""
def __init__(self, options, runfiles):
self.__runfiles = runfiles
self.__options = options
self.__env = _create_jinja_environment(runfiles,
self.__options.site_root,
self.__options.link_ext)
def write(self, rulesets):
# Generate navigation used for all rules.
nav_template = self.__env.get_template('nav.jinja')
nav = nav_template.render(
rulesets=rulesets,
overview=self.__options.overview,
overview_filename=self.__options.overview_filename)
try:
temp_dir = tempfile.mkdtemp()
output_files = []
for ruleset in rulesets:
if not ruleset.empty():
output_files.append(self._write_ruleset(temp_dir, ruleset, nav))
if self.__options.overview:
output_files.append(self._write_overview(temp_dir, rulesets, nav))
if self.__options.output_zip:
with zipfile.ZipFile(self.__options.output_file, 'w') as zf:
for output_file, output_path in output_files:
zf.write(output_file, output_path)
zf.write(_runfile_path(self.__runfiles,
posixpath.join(CSS_PATH, CSS_FILE)),
CSS_FILE)
else:
for output_file, output_path in output_files:
dest_file = os.path.join(self.__options.output_dir, output_path)
dest_dir = os.path.dirname(dest_file)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
shutil.copyfile(output_file, dest_file)
# Copy CSS file.
shutil.copyfile(_runfile_path(self.__runfiles,
posixpath.join(CSS_PATH, CSS_FILE)),
os.path.join(self.__options.output_dir, CSS_FILE))
finally:
# Delete temporary directory.
shutil.rmtree(temp_dir)
def _write_ruleset(self, output_dir, ruleset, nav):
# Load template and render markdown.
template = self.__env.get_template('html.jinja')
out = template.render(title=ruleset.title, ruleset=ruleset, nav=nav)
# Write output to file. Output files are created in a directory structure
# that matches that of the input file.
output_path = ruleset.output_file + '.html'
output_file = "%s/%s" % (output_dir, output_path)
file_dirname = os.path.dirname(output_file)
if not os.path.exists(file_dirname):
os.makedirs(file_dirname)
with open(output_file, "w") as f:
f.write(out)
return (output_file, output_path)
def _write_overview(self, output_dir, rulesets, nav):
template = self.__env.get_template('html_overview.jinja')
out = template.render(title='Overview', rulesets=rulesets, nav=nav)
output_file = "%s/%s.html" % (output_dir, self.__options.overview_filename)
with open(output_file, "w") as f:
f.write(out)
return (output_file, "%s.html" % self.__options.overview_filename)
def main(argv):
parser = OptionParser()
parser.add_option('--output_dir', default='',
help='The directory to write the output generated documentation to if --zip=false')
parser.add_option('--output_file', default='',
help='The output zip archive file to write if --zip=true.')
parser.add_option('--format', default='markdown',
help='The output format. Possible values are markdown and html')
parser.add_option('--zip', action='store_true', default=True,
help='Whether to generate a ZIP arhive containing the output files. If '
'--zip is true, then skydoc will generate a zip file, skydoc.zip by '
'default or as specified by --output_file. If --zip is false, then '
'skydoc will generate documentation, either in Markdown or HTML as '
'specifed by --format, in the current directory or --output_dir if set.')
parser.add_option('--strip_prefix', default='',
help='The directory prefix to strip from all generated docs, which are '
'generated in subdirectories that match the package structure of the '
'input .bzl files. The prefix to strip must be common to all .bzl files; '
'otherwise, skydoc will raise an error.')
parser.add_option('--overview', action='store_true',
help='Whether to generate an overview page')
parser.add_option('--overview_filename', default='index',
help='The file name to use for the overview page.')
parser.add_option('--link_ext', default='html',
help='The file extension used for links in the generated documentation')
parser.add_option('--site_root', default='',
help='The site root to be prepended to all URLs in the generated documentation')
(options, args) = parser.parse_args(argv)
if options.output_dir and options.output_file:
sys.stderr.write('Only one of --output_dir or --output_file can be set.')
sys.exit(1)
if not options.output_dir:
options.output_dir = DEFAULT_OUTPUT_DIR
if not options.output_file:
options.output_file = DEFAULT_OUTPUT_FILE
bzl_files = args[1:]
try:
strip_prefix = common.validate_strip_prefix(options.strip_prefix, bzl_files)
except common.InputError as err:
print(err.message)
sys.exit(1)
runfiles = runfiles_lib.Create()
if not runfiles:
# TODO(laszlocsomor): fix https://github.com/bazelbuild/bazel/issues/6212
# and remove this if-block once Bazel is released with that bugfix.
if (not os.environ.get("RUNFILES_DIR") and
not os.environ.get("RUNFILES_MANIFEST_FILE")):
argv0 = sys.argv[0]
pos = argv0.rfind('%s%s%s' % (os.sep, WORKSPACE_DIR, os.sep))
if pos > -1:
dirpath = argv0[0:pos]
if not os.path.isdir(dirpath):
print("ERROR: Cannot access runfiles directory (%s)" % dirpath)
sys.exit(1)
runfiles = runfiles_lib.CreateDirectoryBased(dirpath)
if not runfiles:
print("ERROR: Cannot load runfiles")
sys.exit(1)
rulesets = []
load_sym_extractor = load_extractor.LoadExtractor()
for bzl_file in bzl_files:
load_symbols = []
try:
load_symbols = load_sym_extractor.extract(bzl_file)
except load_extractor.LoadExtractorError as e:
print("ERROR: Error extracting loaded symbols from %s: %s" %
(bzl_file, str(e)))
sys.exit(2)
# TODO(dzc): Make MacroDocExtractor and RuleDocExtractor stateless.
macro_doc_extractor = macro_extractor.MacroDocExtractor()
rule_doc_extractor = rule_extractor.RuleDocExtractor()
macro_doc_extractor.parse_bzl(bzl_file)
rule_doc_extractor.parse_bzl(bzl_file, load_symbols)
merged_language = merge_languages(macro_doc_extractor.proto(),
rule_doc_extractor.proto())
rulesets.append(
rule.RuleSet(bzl_file, merged_language, macro_doc_extractor.title,
macro_doc_extractor.description, strip_prefix,
options.format))
writer_options = WriterOptions(
options.output_dir, options.output_file, options.zip, options.overview,
options.overview_filename, options.link_ext, options.site_root)
if options.format == "markdown":
markdown_writer = MarkdownWriter(writer_options, runfiles)
markdown_writer.write(rulesets)
elif options.format == "html":
html_writer = HtmlWriter(writer_options, runfiles)
html_writer.write(rulesets)
else:
sys.stderr.write(
'Invalid output format: %s. Possible values are markdown and html'
% options.format)
if __name__ == '__main__':
main(sys.argv)