Files
sphinx/sphinx/directives/code.py
2018-03-03 23:18:38 +09:00

475 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""
sphinx.directives.code
~~~~~~~~~~~~~~~~~~~~~~
:copyright: Copyright 2007-2018 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import codecs
import sys
from difflib import unified_diff
from typing import TYPE_CHECKING
from docutils import nodes
from docutils.parsers.rst import Directive, directives
from docutils.statemachine import ViewList
from sphinx import addnodes
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util import parselinenos
from sphinx.util.nodes import set_source_info
if TYPE_CHECKING:
from typing import Any, Dict, List, Tuple # NOQA
from sphinx.application import Sphinx # NOQA
from sphinx.config import Config # NOQA
logger = logging.getLogger(__name__)
class Highlight(Directive):
"""
Directive to set the highlighting language for code blocks, as well
as the threshold for line numbers.
"""
has_content = False
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = False
option_spec = {
'linenothreshold': directives.unchanged,
}
def run(self):
# type: () -> List[nodes.Node]
if 'linenothreshold' in self.options:
try:
linenothreshold = int(self.options['linenothreshold'])
except Exception:
linenothreshold = 10
else:
linenothreshold = sys.maxsize
return [addnodes.highlightlang(lang=self.arguments[0].strip(),
linenothreshold=linenothreshold)]
def dedent_lines(lines, dedent, location=None):
# type: (List[unicode], int, Any) -> List[unicode]
if not dedent:
return lines
if any(s[:dedent].strip() for s in lines):
logger.warning(__('Over dedent has detected'), location=location)
new_lines = []
for line in lines:
new_line = line[dedent:]
if line.endswith('\n') and not new_line:
new_line = '\n' # keep CRLF
new_lines.append(new_line)
return new_lines
def container_wrapper(directive, literal_node, caption):
# type: (Directive, nodes.Node, unicode) -> nodes.container
container_node = nodes.container('', literal_block=True,
classes=['literal-block-wrapper'])
parsed = nodes.Element()
directive.state.nested_parse(ViewList([caption], source=''),
directive.content_offset, parsed)
if isinstance(parsed[0], nodes.system_message):
msg = __('Invalid caption: %s' % parsed[0].astext())
raise ValueError(msg)
caption_node = nodes.caption(parsed[0].rawsource, '',
*parsed[0].children)
caption_node.source = literal_node.source
caption_node.line = literal_node.line
container_node += caption_node
container_node += literal_node
return container_node
class CodeBlock(Directive):
"""
Directive for a code block with special highlighting or line numbering
settings.
"""
has_content = True
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = False
option_spec = {
'linenos': directives.flag,
'dedent': int,
'lineno-start': int,
'emphasize-lines': directives.unchanged_required,
'caption': directives.unchanged_required,
'class': directives.class_option,
'name': directives.unchanged,
}
def run(self):
# type: () -> List[nodes.Node]
document = self.state.document
code = u'\n'.join(self.content)
location = self.state_machine.get_source_and_line(self.lineno)
linespec = self.options.get('emphasize-lines')
if linespec:
try:
nlines = len(self.content)
hl_lines = parselinenos(linespec, nlines)
if any(i >= nlines for i in hl_lines):
logger.warning(__('line number spec is out of range(1-%d): %r') %
(nlines, self.options['emphasize-lines']),
location=location)
hl_lines = [x + 1 for x in hl_lines if x < nlines]
except ValueError as err:
return [document.reporter.warning(str(err), line=self.lineno)]
else:
hl_lines = None
if 'dedent' in self.options:
location = self.state_machine.get_source_and_line(self.lineno)
lines = code.split('\n')
lines = dedent_lines(lines, self.options['dedent'], location=location)
code = '\n'.join(lines)
literal = nodes.literal_block(code, code)
literal['language'] = self.arguments[0]
literal['linenos'] = 'linenos' in self.options or \
'lineno-start' in self.options
literal['classes'] += self.options.get('class', [])
extra_args = literal['highlight_args'] = {}
if hl_lines is not None:
extra_args['hl_lines'] = hl_lines
if 'lineno-start' in self.options:
extra_args['linenostart'] = self.options['lineno-start']
set_source_info(self, literal)
caption = self.options.get('caption')
if caption:
try:
literal = container_wrapper(self, literal, caption)
except ValueError as exc:
return [document.reporter.warning(str(exc), line=self.lineno)]
# literal will be note_implicit_target that is linked from caption and numref.
# when options['name'] is provided, it should be primary ID.
self.add_name(literal)
return [literal]
class LiteralIncludeReader(object):
INVALID_OPTIONS_PAIR = [
('lineno-match', 'lineno-start'),
('lineno-match', 'append'),
('lineno-match', 'prepend'),
('start-after', 'start-at'),
('end-before', 'end-at'),
('diff', 'pyobject'),
('diff', 'lineno-start'),
('diff', 'lineno-match'),
('diff', 'lines'),
('diff', 'start-after'),
('diff', 'end-before'),
('diff', 'start-at'),
('diff', 'end-at'),
]
def __init__(self, filename, options, config):
# type: (unicode, Dict, Config) -> None
self.filename = filename
self.options = options
self.encoding = options.get('encoding', config.source_encoding)
self.lineno_start = self.options.get('lineno-start', 1)
self.parse_options()
def parse_options(self):
# type: () -> None
for option1, option2 in self.INVALID_OPTIONS_PAIR:
if option1 in self.options and option2 in self.options:
raise ValueError(__('Cannot use both "%s" and "%s" options') %
(option1, option2))
def read_file(self, filename, location=None):
# type: (unicode, Any) -> List[unicode]
try:
with codecs.open(filename, 'r', self.encoding, errors='strict') as f: # type: ignore # NOQA
text = f.read() # type: unicode
if 'tab-width' in self.options:
text = text.expandtabs(self.options['tab-width'])
return text.splitlines(True)
except (IOError, OSError):
raise IOError(__('Include file %r not found or reading it failed') % filename)
except UnicodeError:
raise UnicodeError(__('Encoding %r used for reading included file %r seems to '
'be wrong, try giving an :encoding: option') %
(self.encoding, filename))
def read(self, location=None):
# type: (Any) -> Tuple[unicode, int]
if 'diff' in self.options:
lines = self.show_diff()
else:
filters = [self.pyobject_filter,
self.start_filter,
self.end_filter,
self.lines_filter,
self.prepend_filter,
self.append_filter,
self.dedent_filter]
lines = self.read_file(self.filename, location=location)
for func in filters:
lines = func(lines, location=location)
return ''.join(lines), len(lines)
def show_diff(self, location=None):
# type: (Any) -> List[unicode]
new_lines = self.read_file(self.filename)
old_filename = self.options.get('diff')
old_lines = self.read_file(old_filename)
diff = unified_diff(old_lines, new_lines, old_filename, self.filename) # type: ignore
return list(diff)
def pyobject_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
pyobject = self.options.get('pyobject')
if pyobject:
from sphinx.pycode import ModuleAnalyzer
analyzer = ModuleAnalyzer.for_file(self.filename, '')
tags = analyzer.find_tags()
if pyobject not in tags:
raise ValueError(__('Object named %r not found in include file %r') %
(pyobject, self.filename))
else:
start = tags[pyobject][1]
end = tags[pyobject][2]
lines = lines[start - 1:end]
if 'lineno-match' in self.options:
self.lineno_start = start
return lines
def lines_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
linespec = self.options.get('lines')
if linespec:
linelist = parselinenos(linespec, len(lines))
if any(i >= len(lines) for i in linelist):
logger.warning(__('line number spec is out of range(1-%d): %r') %
(len(lines), linespec), location=location)
if 'lineno-match' in self.options:
# make sure the line list is not "disjoint".
first = linelist[0]
if all(first + i == n for i, n in enumerate(linelist)):
self.lineno_start += linelist[0]
else:
raise ValueError(__('Cannot use "lineno-match" with a disjoint '
'set of "lines"'))
lines = [lines[n] for n in linelist if n < len(lines)]
if lines == []:
raise ValueError(__('Line spec %r: no lines pulled from include file %r') %
(linespec, self.filename))
return lines
def start_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
if 'start-at' in self.options:
start = self.options.get('start-at')
inclusive = False
elif 'start-after' in self.options:
start = self.options.get('start-after')
inclusive = True
else:
start = None
if start:
for lineno, line in enumerate(lines):
if start in line:
if inclusive:
if 'lineno-match' in self.options:
self.lineno_start += lineno + 1
return lines[lineno + 1:]
else:
if 'lineno-match' in self.options:
self.lineno_start += lineno
return lines[lineno:]
if inclusive is True:
raise ValueError('start-after pattern not found: %s' % start)
else:
raise ValueError('start-at pattern not found: %s' % start)
return lines
def end_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
if 'end-at' in self.options:
end = self.options.get('end-at')
inclusive = True
elif 'end-before' in self.options:
end = self.options.get('end-before')
inclusive = False
else:
end = None
if end:
for lineno, line in enumerate(lines):
if end in line:
if inclusive:
return lines[:lineno + 1]
else:
if lineno == 0:
return []
else:
return lines[:lineno]
if inclusive is True:
raise ValueError('end-at pattern not found: %s' % end)
else:
raise ValueError('end-before pattern not found: %s' % end)
return lines
def prepend_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
prepend = self.options.get('prepend')
if prepend:
lines.insert(0, prepend + '\n')
return lines
def append_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
append = self.options.get('append')
if append:
lines.append(append + '\n')
return lines
def dedent_filter(self, lines, location=None):
# type: (List[unicode], Any) -> List[unicode]
if 'dedent' in self.options:
return dedent_lines(lines, self.options.get('dedent'), location=location)
else:
return lines
class LiteralInclude(Directive):
"""
Like ``.. include:: :literal:``, but only warns if the include file is
not found, and does not raise errors. Also has several options for
selecting what to include.
"""
has_content = False
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = True
option_spec = {
'dedent': int,
'linenos': directives.flag,
'lineno-start': int,
'lineno-match': directives.flag,
'tab-width': int,
'language': directives.unchanged_required,
'encoding': directives.encoding,
'pyobject': directives.unchanged_required,
'lines': directives.unchanged_required,
'start-after': directives.unchanged_required,
'end-before': directives.unchanged_required,
'start-at': directives.unchanged_required,
'end-at': directives.unchanged_required,
'prepend': directives.unchanged_required,
'append': directives.unchanged_required,
'emphasize-lines': directives.unchanged_required,
'caption': directives.unchanged,
'class': directives.class_option,
'name': directives.unchanged,
'diff': directives.unchanged_required,
}
def run(self):
# type: () -> List[nodes.Node]
document = self.state.document
if not document.settings.file_insertion_enabled:
return [document.reporter.warning('File insertion disabled',
line=self.lineno)]
env = document.settings.env
# convert options['diff'] to absolute path
if 'diff' in self.options:
_, path = env.relfn2path(self.options['diff'])
self.options['diff'] = path
try:
location = self.state_machine.get_source_and_line(self.lineno)
rel_filename, filename = env.relfn2path(self.arguments[0])
env.note_dependency(rel_filename)
reader = LiteralIncludeReader(filename, self.options, env.config)
text, lines = reader.read(location=location)
retnode = nodes.literal_block(text, text, source=filename)
set_source_info(self, retnode)
if self.options.get('diff'): # if diff is set, set udiff
retnode['language'] = 'udiff'
elif 'language' in self.options:
retnode['language'] = self.options['language']
retnode['linenos'] = ('linenos' in self.options or
'lineno-start' in self.options or
'lineno-match' in self.options)
retnode['classes'] += self.options.get('class', [])
extra_args = retnode['highlight_args'] = {}
if 'emphasize-lines' in self.options:
hl_lines = parselinenos(self.options['emphasize-lines'], lines)
if any(i >= lines for i in hl_lines):
logger.warning(__('line number spec is out of range(1-%d): %r') %
(lines, self.options['emphasize-lines']),
location=location)
extra_args['hl_lines'] = [x + 1 for x in hl_lines if x < lines]
extra_args['linenostart'] = reader.lineno_start
if 'caption' in self.options:
caption = self.options['caption'] or self.arguments[0]
retnode = container_wrapper(self, retnode, caption)
# retnode will be note_implicit_target that is linked from caption and numref.
# when options['name'] is provided, it should be primary ID.
self.add_name(retnode)
return [retnode]
except Exception as exc:
return [document.reporter.warning(str(exc), line=self.lineno)]
def setup(app):
# type: (Sphinx) -> Dict[unicode, Any]
directives.register_directive('highlight', Highlight)
directives.register_directive('highlightlang', Highlight) # old
directives.register_directive('code-block', CodeBlock)
directives.register_directive('sourcecode', CodeBlock)
directives.register_directive('literalinclude', LiteralInclude)
return {
'version': 'builtin',
'parallel_read_safe': True,
'parallel_write_safe': True,
}