Source code for parce.formatter
# -*- coding: utf-8 -*-
#
# This file is part of the parce Python package.
#
# Copyright © 2019-2020 by Wilbert Berendsen <info@wilbertberendsen.nl>
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
The Formatter uses a :mod:`Theme <parce.theme>` to highlight text
according to a token's :mod:`~parce.action` attribute. The action is mapped to
a :class:`~parce.theme.TextFormat` by the theme.
It is possible to add more Themes to a formatter, coupled to a certain
language, so that the formatter can switch to that theme for embedded pieces of
text of that language.
All kinds of text formatting and/or highlighting can be implemented by using
or inheriting of Formatter. If you need to convert the TextFormats from the
theme to something else, you can provide a factory to Formatter to do that.
There is also a :class:`SimpleFormatter` which just churns out the standard
action of each token as a HTML class string, for example mapping
``Literal.Number`` to ``"literal number"``, without needing a Theme.
If you need more special behaviour, you can inherit from Formatter and
reimplement ``format_ranges()``, to do other things or to use a different
FormatContext.
"""
import collections
from . import util
# if a theme provides an "_unparsed" class, unparsed text is
# highlighted by the fomatter
from .standardaction import StandardAction
_Unparsed = StandardAction("_Unparsed")
FormatCache = collections.namedtuple("FormatCache",
"theme base textformat baseformat unparsed")
"""FormatCache is a named tuple encapsulating formatting logic.
At least two attributes must be defined:
``textformat(action)``
is called to return formatting information for the specified standard
action.
``baseformat(role, state)``
is called to return general formatting information from a Theme, converted
using the factory that was given to the formatter. See the
:meth:`~parce.theme.Theme.baseformat` method of :class:`~parce.theme.Theme`.
Both callables may return None. The three other attributes can be None; they
are:
``theme``
a reference to the format cache's Theme object.
``base``
the result of ``baseformat("window", "default")``, indicating the general
format of the text window (color, font, background color, etc). See the
:meth:`~parce.theme.Theme.baseformat` method of :class:`~parce.theme.Theme`.
``unparsed``
the result of ``textformat(StandardAction("_Unparsed"))``, which denotes
the text format to use for unparsed text. A Theme can define that by putting
properties in the ``.parce ._unparsed`` class. By default unparsed text is
not formatted.
"""
FormatRange = collections.namedtuple("FormatRange", "pos end textformat")
"""A named tuple denoting a text range from ``pos`` to ``end`` that should be
formatted with ``textformat``.
The textformat can be any object, that depends on the factory function that is
used to convert a standard action or (when using a :class:`~parce.theme.Theme`)
a :class:`~parce.theme.TextFormat` to something you can use for the output
format you want to create.
"""
[docs]class AbstractFormatter:
"""A Formatter formats text based on the action of tokens."""
def __repr__(self):
name = self.__class__.__name__
themes = ', '.join(repr(fc.theme) for fc in self.format_caches().values())
return '<{} [{}]>'.format(name, themes)
[docs] def baseformat(self, role="window", state="default", language=None):
"""Return the base format for the specified ``role`` and ``state``.
This is the value returned by :meth:`Theme.baseformat(role, state)
<parce.theme.Theme.baseformat>`, converted by our factory (and cached
of course).
If the ``language`` is given, the theme added for that language is
consulted.
If the theme does not provide a baseformat, or no theme was added for the
specified language, None is returned.
"""
try:
return self.format_caches()[language].baseformat(role, state)
except KeyError:
pass
[docs] def textformat(self, action, language=None):
"""Return the text format for the specified action.
This is the value returned by :meth:`Theme.textformat(action)
<parce.theme.Theme.textformat>`, converted by our factory (and cached
of course).
If the ``language`` is given, the theme added for that language is
consulted.
If the theme does not provide a text format for the action, or no theme
was added for the specified language, None is returned.
"""
try:
return self.format_caches()[language].textformat(action)
except KeyError:
pass
[docs] def format_caches(self):
"""Should return a dictionary mapping language to FormatCache.
A :class:`FormatCache` normally encapsulates a theme. The key None
should be present and denotes the default theme. Other keys should be
:class:`~parce.language.Language` subclasses, and their theme is used
for tokens that originate from that language.
"""
raise NotImplementedError
[docs] def format_ranges(self, tree, start=0, end=None, format_context=None):
"""Yield FormatRange(pos, end, format) three-tuples.
The ``format`` is the value returned by ``Theme.textformat()`` for the
token's action, converted by our factory (and cached of course).
Ranges with a TextFormat for which our factory returns None are
skipped.
"""
r = tree.range(start, end)
if not r:
return
format_caches = self.format_caches() # caches for all added themes
cache = format_caches.get # quick access
fc = default_fcache = cache(None) # the default FormatCache
if fc is None:
# there is no default theme, don't yield tokens and use empty fc
fc = FormatCache(None, None, lambda action: None,
lambda role, state: None, None)
def tokens():
return
yield
elif len(format_caches) == 1:
# language will never be switched, no need to follow language
def tokens():
return r.tokens()
else:
# language can potentially switch, follow it
def tokens():
nonlocal fc
curlang = None
# Modifies curlang and current format cache fc if lang changes
def check_lang(lang):
nonlocal curlang, fc
if lang is not curlang:
curlang = lang
nfc = cache(lang, default_fcache)
if nfc is not fc:
fc = nfc
if format_context:
format_context.switch(fc)
for context, slice_ in r.slices():
check_lang(context.lexicon.language)
n = context[slice_]
stack = []
i = 0
while True:
for i in range(i, len(n)):
m = n[i]
if m.is_context:
stack.append(i)
i = 0
n = m
check_lang(n.lexicon.language)
break
yield m
else:
if stack:
n = n.parent
check_lang(n.lexicon.language)
i = stack.pop() + 1
else:
break
if fc.unparsed is not None:
# Yield the unparsed format between tokens
def stream():
nonlocal fc
unparsed = fc.unparsed # store it, fc can change
prev_end = start
for t in tokens():
if t.pos > prev_end:
yield prev_end, t.pos, unparsed
prev_end = t.end
f = fc.textformat(t.action)
if f is None:
f = fc.base
if f is not None:
yield t.pos, t.end, f
if end is not None and prev_end < end:
yield prev_end, end, unparsed
else:
# yield fc.base (if defined) between tokens
def stream():
nonlocal fc
prev_end = start
for t in tokens():
f = fc.textformat(t.action)
if f is not None:
if fc.base is not None and t.pos > prev_end:
yield prev_end, t.pos, fc.base
yield t.pos, t.end, f
prev_end = t.end
if fc.base is not None and end is not None and prev_end < end:
yield prev_end, end, fc.base
format_context and format_context.start(fc)
yield from util.merge_adjacent(util.fix_boundaries(
stream(), start, end), FormatRange)
format_context and format_context.done()
[docs] def format_text(self, text, tree, start=0, end=None, format_context=None):
"""Yield all text in tuples(text, format).
For unparsed pieces of text, or pieces that had no format mapped to the
action, the format is None. The FormatContext, if given, is passed on
to :meth:`format_ranges`.
"""
prev_end = start
for r in self.format_ranges(tree, start, end, format_context):
if r.pos > prev_end:
yield text[prev_end:r.pos], None
yield text[r.pos:r.end], r.textformat
prev_end = r.end
if end is None:
end = len(text)
if end > prev_end:
yield text[prev_end:end], None
[docs] def format_document(self, cursor, format_context=None):
"""Yield all text in the cursor's selection in tuples(text, format).
For unparsed pieces of text, or pieces that had no format mapped to the
action, the format is None.The FormatContext, if given, is passed on
to :meth:`format_ranges`. For example::
>>> from parce import Cursor, Document, theme_by_name
>>> from parce.lang.css import Css
>>> from parce.formatter import Formatter
>>> factory = lambda tf: tf.css_properties() or None
>>> f = Formatter(theme_by_name(), factory)
>>> d = Document(Css.root, "h1 { color: red; }")
>>> c = Cursor(d).select_all()
>>> list(f.format_document(c))
[('h1', {'color': '#00008b', 'font-weight': 'bold'}),
(' ', None),
('{', {'font-weight': 'bold'}),
(' ', None),
('color', {'color': '#4169e1', 'font-weight': 'bold'}),
(': ', None), ('red', {'color': '#2e8b57'}),
('; ', None),
('}', {'font-weight': 'bold'})]
"""
if cursor.has_selection():
doc = cursor.document()
yield from self.format_text(
doc.text(), doc.get_root(True), cursor.pos, cursor.end, format_context)
[docs]class Formatter(AbstractFormatter):
"""A Formatter is used to format or highlight text according to a Theme.
Supply the theme, and an optional factory that converts a TextFormat to
something else. For example::
>>> from parce import root, find, theme_by_name
>>> from parce.formatter import Formatter
>>> tree = root(find("css"), "h1 { color: red; }")
>>> f = Formatter(theme_by_name('default'))
>>> list(f.format_ranges(tree))
[FormatRange(pos=0, end=2, textformat=<TextFormat color=Color(r=0, g=0,b=139, a=1.0), font_weight='bold'>),
FormatRange(pos=3, end=4, textformat=<TextFormat font_weight='bold'>),
FormatRange(pos=5, end=10, textformat=<TextFormat color=Color(r=65, g=105, b=225, a=1.0), font_weight='bold'>),
FormatRange(pos=12, end=15, textformat=<TextFormat color=Color(r=46, g=139, b=87, a=1.0)>),
FormatRange(pos=17, end=18, textformat=<TextFormat font_weight='bold'>)]
The default factory just yields the TextFormat right from the theme, unless
the format is empty, evaluating to None.
And here is an example using a factory that converts the textformat to a
dictionary of css properties, e.g. to use for inline CSS highlighting. Note
that when the factory returns None, a range is skipped, so we return None
in case a dictionary ends up empty::
>>> factory = lambda tf: tf.css_properties() or None
>>> f = Formatter(theme_by_name('default'), factory)
>>> list(f.format_ranges(tree))
[FormatRange(pos=0, end=2, textformat={'color': '#00008b', 'font-weight': 'bold'}),
FormatRange(pos=3, end=4, textformat={'font-weight': 'bold'}),
FormatRange(pos=5, end=10, textformat={'color': '#4169e1', 'font-weight': 'bold'}),
FormatRange(pos=12, end=15, textformat={'color': '#2e8b57'}),
FormatRange(pos=17, end=18, textformat={'font-weight': 'bold'})]
In addition to the default theme (which is required), other themes can be
added coupled to a specific language. This allows the formatter to switch
theme based on the language of the text.
"""
def __init__(self, theme=None, factory=None):
if factory is None:
factory = lambda f: f or None
self._factory = factory
self._format_caches = {}
if theme is not None:
self.add_theme(theme)
[docs] def format_caches(self):
"""Reimplemented to return the format caches added by add_theme().
The format cache caches formatting information from the theme, to
enable fast formatting.
"""
return self._format_caches
[docs] def add_theme(self, theme, language=None, add_baseformat=False):
"""Add a Theme.
If ``language`` is None, the theme becomes the default theme. If a
:class:`~parce.language.Language` is specified, the theme will be used
for tokens from that language.
If ``add_baseformat`` is True, the theme's baseformat (window) will be
added to all the theme's text formats.
"""
if add_baseformat:
base_ = theme.baseformat()
base = self._factory(base_)
@util.cached_func
def factory(action):
return self._factory(base_ + theme.textformat(action))
else:
base = None
@util.cached_func
def factory(action):
return self._factory(theme.textformat(action))
@util.cached_func
def baseformat(role, state):
return self._factory(theme.baseformat(role, state))
unparsed = self._factory(theme.textformat(_Unparsed))
self.format_caches()[language] = \
FormatCache(theme, base, factory, baseformat, unparsed)
[docs] def get_theme(self, language=None):
"""Return the theme for the specified language.
If language is None, the default theme is returned.
Returns None if the language has no specific theme.
"""
try:
return self.format_caches()[language].theme
except KeyError:
pass
[docs] def remove_theme(self, language):
"""Remove the theme for the specified language."""
del self.format_caches()[language]
[docs] def copy_themes(self, formatter):
"""Copy all themes from the other formatter."""
self.format_caches().clear()
for language, fc in formatter.format_caches().items():
self.add_theme(fc.theme, language, fc.base is not None)
[docs]class SimpleFormatter(AbstractFormatter):
"""A formatter that simply yields a HTML class string for every action.
For example::
>>> import parce.formatter
>>> tree = parce.root(parce.find("css"), "h1 { color: red; }")
>>> f = parce.formatter.SimpleFormatter()
>>> list(f.format_ranges(tree))
[FormatRange(pos=0, end=2, textformat='name tag'),
FormatRange(pos=3, end=4, textformat='delimiter bracket'),
FormatRange(pos=5, end=10, textformat='name property definition'),
FormatRange(pos=10, end=11, textformat='delimiter'),
FormatRange(pos=12, end=15, textformat='literal color'),
FormatRange(pos=15, end=16, textformat='delimiter'),
FormatRange(pos=17, end=18, textformat='delimiter bracket')]
This formatter does not use a theme; language switches are ignored.
"""
[docs] def format_caches(self):
"""Reimplemented to return a FormatCache with a factory that converts
an action to a css class string.
"""
from parce.theme import css_class
baseformat = lambda role, state: None
return {None: FormatCache(None, None, css_class, baseformat, None)}
[docs]class FormatContext:
"""FormatContext can be used to track theme changes during formatting.
A FormatContext instance can be given to the
:meth:`AbstractFormatter.format_ranges` method of a formatter.
Inheriting from this class and implementing the methods enable you to
react to theme changes during formatting.
"""
[docs] def start(self, fcache):
"""Called when formatting starts, with the default Theme's format cache."""