diff options
author | Eevee (Alex Munroe) <eevee.git@veekun.com> | 2014-08-28 17:03:16 -0700 |
---|---|---|
committer | Eevee (Alex Munroe) <eevee.git@veekun.com> | 2014-09-01 21:30:56 -0700 |
commit | 80c8b5e61dac86efe686f924d8670cc6fed21575 (patch) | |
tree | 8189771a8d6da681dfec43725ec16866429eb79c | |
parent | 3c96edaa71ace1d28ba930c251f7880b0edf5167 (diff) | |
download | pyscss-80c8b5e61dac86efe686f924d8670cc6fed21575.tar.gz |
WIP: Taking a crack at separating block parsing from evaluation.
-rw-r--r-- | scss/blockast.py | 234 | ||||
-rw-r--r-- | scss/compiler.py | 176 | ||||
-rw-r--r-- | scss/expression.py | 1 |
3 files changed, 409 insertions, 2 deletions
diff --git a/scss/blockast.py b/scss/blockast.py new file mode 100644 index 0000000..153f7aa --- /dev/null +++ b/scss/blockast.py @@ -0,0 +1,234 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import division + +import logging + +from scss.expression import Calculator +from scss.types import List +from scss.types import Null +from scss.util import normalize_var # TODO put in... namespace maybe? + + +log = logging.getLogger(__name__) + + +# TODO currently none of these know where they come from; whoops? + +class Node(object): + is_block = False + """Whether this node is a block, i.e. has children.""" + + def __repr__(self): + return "<{0} {1!r}>".format( + type(self).__name__, + repr(self.__dict__)[:100], + ) + + def add_child(self, node): + raise RuntimeError("Only blocks can have children") + + def evaluate(self, namespace): + raise NotImplementedError + + +class Declaration(Node): + pass + +class Assignment(Declaration): + def __init__(self, name, value): + self.name = name + self.value = value + +class CSSDeclaration(Declaration): + def __init__(self, prop, value_expression): + self.prop = prop + self.value_expression = value_expression + + @classmethod + def parse(cls, prop, value): + # TODO prop needs parsing too, but interp-only! + + # TODO this is a bit naughty, but uses no state except the ast_cache -- + # which should maybe be in the Compiler anyway...? + value_expression = Calculator().parse_expression(value) + + return cls(prop, value_expression) + + def evaluate(self, compilation): + prop = self.prop + value = self.value_expression.evaluate(compilation.current_calculator) + # TODO this is where source maps would need to get their info + compilation.add_declaration(prop, value) + + +class _AtRuleMixin(object): + + is_atrule = True + + directive = None + """The name of the at-rule, including the @.""" + + argument = None + """Any text between the at-rule and the opening brace.""" + # TODO should this be parsed? can't be for unknown rules... + + def __init__(self, directive, argument): + super(_AtRuleMixin, self).__init__() + self.directive = directive + self.argument = argument + + def __repr__(self): + return "<%s %r %r>" % (type(self).__name__, self.directive, self.argument) + + def render(self): + if self.argument: + return "%s %s" % (self.directive, self.argument) + else: + return self.directive + + def evaluate(*args): + log.warn("Not yet implemented") + +class AtRule(_AtRuleMixin, Node): + """An at-rule with no children, e.g. ``@import``.""" + + +class Block(Node): + """Base class for a block -- an arbitrary header followed by a list of + children delimited with curly braces. + """ + is_block = True + + def __init__(self): + self.children = [] + + def add_child(self, node): + self.children.append(node) + + +class SelectorBlock(Block): + """A regular CSS-like block, with selectors for the header and a list of + zero or more declarations. This is the most likely kind of node to end up + as CSS output. + """ + is_selector = True + + def __init__(self, selector_string): + super(SelectorBlock, self).__init__() + # NOTE: This can't really be parsed as a selector yet, since it might + # have interpolations that alter the selector parsing. + # TODO eager-parse if there's no #{ in it? + self.selector_string = selector_string + + def __repr__(self): + return "<%s %r>" % (type(self).__name__, self.selector_string) + + def evaluate(self, compilation): + # TODO parse earlier; need a rule that ONLY looks for interps + plain_selector_string = compilation.current_calculator.do_glob_math(self.selector_string) + from .selector import Selector + selector = Selector.parse_many(plain_selector_string) + with compilation.nest_selector(selector): + # TODO un-recurse + for child in self.children: + child.evaluate(compilation) + + +class AtRuleBlock(_AtRuleMixin, Block): + """An at-rule followed by a block, e.g. ``@media``.""" + + +class ScopeBlock(Block): + """A block that looks like ``background: { color: white; }``. This is + Sass shorthand for setting multiple properties with a common prefix. + """ + is_scope = True + + def __init__(self, scope, unscoped_value): + super(ScopeBlock, self).__init__() + self.scope = scope + + if unscoped_value: + self.unscoped_value = unscoped_value + else: + self.unscoped_value = None + + def add_child(self, node): + # TODO pretty sure you can't, say, nest a scope block inside a scope + # block + super(ScopeBlock, self).add_child(node) + + +class FileBlock(Block): + """Special block representing the entire contents of a file. ONLY has + children; there's no header. + """ + + # TODO can only contain blocks + + def evaluate(self, compilation): + for child in self.children: + child.evaluate(compilation) + + +class AtEachBlock(AtRuleBlock): + directive = '@each' + + def __init__(self, variable_names, expression, unpack): + # TODO fix parent to not assign to directive/argument and use super + # here + Block.__init__(self) + + self.variable_names = variable_names + self.expression = expression + self.unpack = unpack + + @classmethod + def parse(cls, argument): + # TODO this is flaky; need a real grammar rule here + varstring, _, valuestring = argument.partition(' in ') + # TODO this is a bit naughty, but uses no state except the ast_cache -- + # which should maybe be in the Compiler anyway...? + expression = Calculator().parse_expression(valuestring) + + variable_names = [ + # TODO broke support for #{} inside the var name + normalize_var(var.strip()) + # TODO use list parsing here + for var in varstring.split(",") + ] + + # `@each $foo, in $bar` unpacks, but `@each $foo in $bar` does not! + unpack = len(variable_names) > 1 + if not variable_names[-1]: + variable_names.pop() + + return cls(variable_names, expression, unpack) + + def evaluate(self, compilation): + # TODO with compilation.new_scope() as namespace: + namespace = compilation.current_namespace + + # TODO compilation.calculator? or change calculator to namespace? + calc = Calculator(namespace) + values = self.expression.evaluate(calc) + # TODO is the from_maybe necessary here? doesn't Value do __iter__? + for values in List.from_maybe(values): + if self.unpack: + values = List.from_maybe(values) + for i, variable_name in enumerate(self.variable_names): + if i >= len(values): + value = Null() + else: + value = values[i] + namespace.set_variable(variable_name, value) + else: + namespace.set_variable(self.variable_names[0], values) + + # TODO i would love to get this recursion out of here -- clever use + # of yield? + for child in self.children: + child.evaluate(compilation) + diff --git a/scss/compiler.py b/scss/compiler.py index b6d8851..8fceed1 100644 --- a/scss/compiler.py +++ b/scss/compiler.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals from __future__ import division from collections import defaultdict +from contextlib import contextmanager from enum import Enum import glob from itertools import product @@ -51,6 +52,7 @@ from scss.types import Undefined from scss.types import Url from scss.util import normalize_var # TODO put in... namespace maybe? +from .blockast import * # TODO should mention logging for the programmatic interface in the # documentation @@ -335,6 +337,32 @@ class Compilation(object): self.rules.append(rule) children.append(rule) + # TODO would be neat to cache the ASTs for files in the compiler + file_block = self.parse_blocks(source_file.contents) + + # The compilation doubles as the evaluation context, keeping track of + # running state while evaluating the AST here + # TODO maybe stateless stuff, like parsing, should go exclusively on + # the compiler then? + # TODO in which case maybe evaluation should go here and Calculator + # should vanish? (but then it's awkward to use programmatically, hmm) + self._ancestry_stack = [RuleAncestry()] + self.current_namespace = root_namespace + self.current_calculator = self._make_calculator(self.current_namespace) + self.declarations = [] + + file_block.evaluate(self) + + for ancestry, properties in self.declarations: + for header in ancestry.headers: + print(header.render(), end=' ') + print("{") + for prop, value in properties: + print(" {0}: {1};".format(prop, value.render())) + print("}") + return + + for rule in children: self.manage_children(rule, scope) @@ -355,6 +383,127 @@ class Compilation(object): undefined_variables_fatal=self.compiler.undefined_variables_fatal, ) + def parse_blocks(self, string, scope=''): + # TODO eliminate scope by making it a block attribute + from collections import deque + remaining = deque() + + # TODO i guess root should be a node too? what kind? + root = FileBlock() + + remaining.append((root, string)) + while remaining: + parent, string = remaining.popleft() + for lineno, header_string, child_string in locate_blocks(string): + if child_string is None: + # No actual block here; either a simple declaration (like + # @import) or a property assignment + pass + # TODO include source in these + # TODO classes, obv. + + is_block = child_string is not None + node = self.parse_header(parent, header_string, is_block) + parent.add_child(node) + if is_block: + remaining.append((node, child_string)) + + print(lineno, repr(header_string), repr(node)) + from pprint import pprint + print() + pprint(root) + + return root + + def parse_header(self, parent_block, header_string, is_block): + """Given a line number, header string, and whether or not this is a + block as opposed to a declaration (roughly the output of + `locate_blocks`), return an appropriate AST node. Note that this does + NOT actually parse the children; it returns an empty container. + """ + # TODO how do the rest of the properties like source_file get in here? + # just pass in the parent, maybe, and let each of the AST classes + # figure out what to do at runtime? i do kinda like that. + header_string = header_string.strip() + + # Simple pre-processing + if header_string.startswith('+') and not is_block: + # Expand '+' at the beginning of a rule as @include. But not if + # there's a block, because that's probably a CSS selector. + # DEVIATION: this is some semi hybrid of Sass and xCSS syntax + # TODO: warn_deprecated + header_string = '@include ' + header_string[1:] + try: + if '(' not in header_string or header_string.index(':') < header_string.index('('): + header_string = header_string.replace(':', '(', 1) + if '(' in header_string: + header_string += ')' + except ValueError: + pass + elif header_string.startswith('='): + # Expand '=' at the beginning of a rule as @mixin + header_string = '@mixin ' + header_string[1:] + elif header_string.startswith('@prototype '): + # Remove '@prototype ' + # TODO what is @prototype?? + # TODO warn_deprecated(...) + header_string = header_string[11:] + + # Now we decide what kind of thing we actually have. + if header_string.startswith('@'): + if header_string.lower().startswith('@else if '): + directive = '@else if' + argument = header_string[9:] + else: + chunks = header_string.split(None, 1) + if len(chunks) == 2: + directive, argument = chunks + else: + directive, argument = header_string, None + directive = directive.lower() + + if is_block: + # TODO need some real registration+dispatch here + if directive == '@each': + return AtEachBlock.parse(argument) + else: + return AtRuleBlock(directive, argument) + else: + return AtRule(directive, argument) + + # Try splitting this into "name : value" + # TODO strictly speaking this isn't right -- consider foo#{":"}bar. + # the right fix is probably to shift this into the grammar as its own + # goal rule... but that doesn't work in a selector, argh! + name, colon, value = header_string.partition(':') + if colon: + name = name.rstrip() + value = value.lstrip() + if is_block: + # This is a "scope" block. Syntax is `<scope>: [value]` -- if + # the optional value exists, it becomes the first declaration, + # with no suffix. + scope = name + unscoped_value = value + # TODO parse value + return ScopeBlock(scope, unscoped_value) + elif name.startswith('$'): + # Sass variable assignment + # TODO parse value, pull off !default and !global + return Assignment(name, value) + else: + # Regular old CSS property declaration + return CSSDeclaration.parse(name, value) + + # If it's nothing else special, it must be a plain old selector block + # TODO need the xcss parsing here + if is_block: + return SelectorBlock(header_string) + + # TODO this should (a) not be a syntax error, (b) get the right stack + # trace + raise SyntaxError("Couldn't figure out what kind of block this is") + # @print_timing(4) def manage_children(self, rule, scope): try: @@ -1052,8 +1201,9 @@ class Compilation(object): if not val: inner_rule = rule.copy() inner_rule.unparsed_contents = block.unparsed_contents - inner_rule.namespace = rule.namespace # DEVIATION: Commenting this line gives the Sass bahavior - inner_rule.unparsed_contents = block.unparsed_contents + if not self.should_scope_loop_in_rule(inner_rule): + # DEVIATION: Allow not creating a new namespace + inner_rule.namespace = rule.namespace self.manage_children(inner_rule, scope) # @print_timing(10) @@ -1437,6 +1587,28 @@ class Compilation(object): return rules_by_file, css_files + # State-munging helpers + + @property + def current_ancestry(self): + return self._ancestry_stack[-1] + + @contextmanager + def nest_selector(self, selector): + new_ancestry = self._ancestry_stack[-1].with_nested_selectors(selector) + self._ancestry_stack.append(new_ancestry) + try: + yield new_ancestry + finally: + self._ancestry_stack.pop() + + def add_declaration(self, prop, value): + if self.declarations and self.declarations[-1][0] == self.current_ancestry: + self.declarations[-1][1].append((prop, value)) + else: + self.declarations.append((self.current_ancestry, [(prop, value)])) + + # @print_timing(3) def create_css(self, rules): """ diff --git a/scss/expression.py b/scss/expression.py index 39eb623..a51fef4 100644 --- a/scss/expression.py +++ b/scss/expression.py @@ -181,4 +181,5 @@ class Calculator(object): return Literal(String.unquoted(string)) return self.parse_expression(string, 'goal_interpolated_anything') + __all__ = ('Calculator',) |