1618 lines
62 KiB
Python
1618 lines
62 KiB
Python
#!/usr/bin/env python
|
|
#-*- coding: utf-8 -*-
|
|
"""
|
|
pyScss, a Scss compiler for Python
|
|
|
|
@author German M. Bravo (Kronuz) <german.mb@gmail.com>
|
|
@version 1.2.0 alpha
|
|
@see https://github.com/Kronuz/pyScss
|
|
@copyright (c) 2012-2013 German M. Bravo (Kronuz)
|
|
@license MIT License
|
|
http://www.opensource.org/licenses/mit-license.php
|
|
|
|
pyScss compiles Scss, a superset of CSS that is more powerful, elegant and
|
|
easier to maintain than plain-vanilla CSS. The library acts as a CSS source code
|
|
preprocesor which allows you to use variables, nested rules, mixins, andhave
|
|
inheritance of rules, all with a CSS-compatible syntax which the preprocessor
|
|
then compiles to standard CSS.
|
|
|
|
Scss, as an extension of CSS, helps keep large stylesheets well-organized. It
|
|
borrows concepts and functionality from projects such as OOCSS and other similar
|
|
frameworks like as Sass. It's build on top of the original PHP xCSS codebase
|
|
structure but it's been completely rewritten, many bugs have been fixed and it
|
|
has been extensively extended to support almost the full range of Sass' Scss
|
|
syntax and functionality.
|
|
|
|
Bits of code in pyScss come from various projects:
|
|
Compass:
|
|
(c) 2009 Christopher M. Eppstein
|
|
http://compass-style.org/
|
|
Sass:
|
|
(c) 2006-2009 Hampton Catlin and Nathan Weizenbaum
|
|
http://sass-lang.com/
|
|
xCSS:
|
|
(c) 2010 Anton Pawlik
|
|
http://xcss.antpaw.org/docs/
|
|
|
|
"""
|
|
from __future__ import absolute_import
|
|
from __future__ import print_function
|
|
|
|
from scss.scss_meta import BUILD_INFO, PROJECT, VERSION, REVISION, URL, AUTHOR, AUTHOR_EMAIL, LICENSE
|
|
|
|
__project__ = PROJECT
|
|
__version__ = VERSION
|
|
__author__ = AUTHOR + ' <' + AUTHOR_EMAIL + '>'
|
|
__license__ = LICENSE
|
|
|
|
|
|
from collections import defaultdict
|
|
import glob
|
|
from itertools import product
|
|
import logging
|
|
import os.path
|
|
import re
|
|
import sys
|
|
|
|
import six
|
|
|
|
from scss import config
|
|
from scss.cssdefs import (
|
|
SEPARATOR,
|
|
_ml_comment_re, _sl_comment_re,
|
|
_escape_chars_re,
|
|
_spaces_re, _expand_rules_space_re, _collapse_properties_space_re,
|
|
_strings_re, _prop_split_re,
|
|
)
|
|
from scss.errors import SassError
|
|
from scss.expression import Calculator
|
|
from scss.functions import ALL_BUILTINS_LIBRARY
|
|
from scss.functions.compass.sprites import sprite_map
|
|
from scss.rule import Namespace, SassRule, UnparsedBlock
|
|
from scss.types import Boolean, List, Null, Number, String, Undefined
|
|
from scss.util import dequote, normalize_var, print_timing # profile
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
################################################################################
|
|
# Load C acceleration modules
|
|
locate_blocks = None
|
|
try:
|
|
from scss._speedups import locate_blocks
|
|
except ImportError:
|
|
import warnings
|
|
warnings.warn(
|
|
"Scanning acceleration disabled (_speedups not found)!",
|
|
RuntimeWarning
|
|
)
|
|
from scss._native import locate_blocks
|
|
|
|
################################################################################
|
|
|
|
|
|
_safe_strings = {
|
|
'^doubleslash^': '//',
|
|
'^bigcopen^': '/*',
|
|
'^bigcclose^': '*/',
|
|
'^doubledot^': ':',
|
|
'^semicolon^': ';',
|
|
'^curlybracketopen^': '{',
|
|
'^curlybracketclosed^': '}',
|
|
}
|
|
_reverse_safe_strings = dict((v, k) for k, v in _safe_strings.items())
|
|
_safe_strings_re = re.compile('|'.join(map(re.escape, _safe_strings)))
|
|
_reverse_safe_strings_re = re.compile('|'.join(map(re.escape, _reverse_safe_strings)))
|
|
|
|
_default_scss_vars = {
|
|
'$BUILD-INFO': String.unquoted(BUILD_INFO),
|
|
'$PROJECT': String.unquoted(PROJECT),
|
|
'$VERSION': String.unquoted(VERSION),
|
|
'$REVISION': String.unquoted(REVISION),
|
|
'$URL': String.unquoted(URL),
|
|
'$AUTHOR': String.unquoted(AUTHOR),
|
|
'$AUTHOR-EMAIL': String.unquoted(AUTHOR_EMAIL),
|
|
'$LICENSE': String.unquoted(LICENSE),
|
|
|
|
# unsafe chars will be hidden as vars
|
|
'$--doubleslash': String.unquoted('//'),
|
|
'$--bigcopen': String.unquoted('/*'),
|
|
'$--bigcclose': String.unquoted('*/'),
|
|
'$--doubledot': String.unquoted(':'),
|
|
'$--semicolon': String.unquoted(';'),
|
|
'$--curlybracketopen': String.unquoted('{'),
|
|
'$--curlybracketclosed': String.unquoted('}'),
|
|
|
|
# shortcuts (it's "a hidden feature" for now)
|
|
'bg:': String.unquoted('background:'),
|
|
'bgc:': String.unquoted('background-color:'),
|
|
}
|
|
|
|
|
|
################################################################################
|
|
|
|
|
|
class SourceFile(object):
|
|
def __init__(self, filename, contents, parent_dir='.', is_string=False, is_sass=None, line_numbers=True, line_strip=True):
|
|
self.filename = filename
|
|
self.sass = filename.endswith('.sass') if is_sass is None else is_sass
|
|
self.line_numbers = line_numbers
|
|
self.line_strip = line_strip
|
|
self.contents = self.prepare_source(contents)
|
|
self.parent_dir = os.path.realpath(parent_dir)
|
|
self.is_string = is_string
|
|
|
|
def __repr__(self):
|
|
return "<SourceFile '%s' at 0x%x>" % (
|
|
self.filename,
|
|
id(self),
|
|
)
|
|
|
|
@classmethod
|
|
def from_filename(cls, fn, filename=None, is_sass=None, line_numbers=True):
|
|
if filename is None:
|
|
_, filename = os.path.split(fn)
|
|
|
|
with open(fn) as f:
|
|
contents = f.read()
|
|
|
|
return cls(filename, contents, is_sass=is_sass, line_numbers=line_numbers)
|
|
|
|
@classmethod
|
|
def from_string(cls, string, filename=None, is_sass=None, line_numbers=True):
|
|
if filename is None:
|
|
filename = "<string %r...>" % string[:50]
|
|
|
|
return cls(filename, string, is_string=True, is_sass=is_sass, line_numbers=line_numbers)
|
|
|
|
def parse_scss_line(self, line_no, line, state):
|
|
ret = ''
|
|
|
|
if line is None:
|
|
line = ''
|
|
|
|
line = state['line_buffer'] + line.rstrip() # remove EOL character
|
|
|
|
if line and line[-1] == '\\':
|
|
state['line_buffer'] = line[:-1]
|
|
return ''
|
|
else:
|
|
state['line_buffer'] = ''
|
|
|
|
output = state['prev_line']
|
|
if self.line_strip:
|
|
output = output.strip()
|
|
output_line_no = state['prev_line_no']
|
|
|
|
state['prev_line'] = line
|
|
state['prev_line_no'] = line_no
|
|
|
|
if output:
|
|
if self.line_numbers:
|
|
output = str(output_line_no + 1) + SEPARATOR + output
|
|
output += '\n'
|
|
ret += output
|
|
|
|
return ret
|
|
|
|
def parse_sass_line(self, line_no, line, state):
|
|
ret = ''
|
|
|
|
if line is None:
|
|
line = ''
|
|
|
|
line = state['line_buffer'] + line.rstrip() # remove EOL character
|
|
|
|
if line and line[-1] == '\\':
|
|
state['line_buffer'] = line[:-1]
|
|
return ret
|
|
else:
|
|
state['line_buffer'] = ''
|
|
|
|
indent = len(line) - len(line.lstrip())
|
|
|
|
# make sure we support multi-space indent as long as indent is consistent
|
|
if indent and not state['indent_marker']:
|
|
state['indent_marker'] = indent
|
|
|
|
if state['indent_marker']:
|
|
indent /= state['indent_marker']
|
|
|
|
if indent == state['prev_indent']:
|
|
# same indentation as previous line
|
|
if state['prev_line']:
|
|
state['prev_line'] += ';'
|
|
elif indent > state['prev_indent']:
|
|
# new indentation is greater than previous, we just entered a new block
|
|
state['prev_line'] += ' {'
|
|
state['nested_blocks'] += 1
|
|
else:
|
|
# indentation is reset, we exited a block
|
|
block_diff = state['prev_indent'] - indent
|
|
if state['prev_line']:
|
|
state['prev_line'] += ';'
|
|
state['prev_line'] += ' }' * block_diff
|
|
state['nested_blocks'] -= block_diff
|
|
|
|
output = state['prev_line']
|
|
if self.line_strip:
|
|
output = output.strip()
|
|
output_line_no = state['prev_line_no']
|
|
|
|
state['prev_indent'] = indent
|
|
state['prev_line'] = line
|
|
state['prev_line_no'] = line_no
|
|
|
|
if output:
|
|
if self.line_numbers:
|
|
output = str(output_line_no + 1) + SEPARATOR + output
|
|
output += '\n'
|
|
ret += output
|
|
return ret
|
|
|
|
def prepare_source(self, codestr, sass=False):
|
|
# Decorate lines with their line numbers and a delimiting NUL and remove empty lines
|
|
state = {
|
|
'line_buffer': '',
|
|
'prev_line': '',
|
|
'prev_line_no': 0,
|
|
'prev_indent': 0,
|
|
'nested_blocks': 0,
|
|
'indent_marker': 0,
|
|
}
|
|
if self.sass:
|
|
parse_line = self.parse_sass_line
|
|
else:
|
|
parse_line = self.parse_scss_line
|
|
_codestr = codestr
|
|
codestr = ''
|
|
for line_no, line in enumerate(_codestr.splitlines()):
|
|
codestr += parse_line(line_no, line, state)
|
|
codestr += parse_line(None, None, state) # parse the last line stored in prev_line buffer
|
|
|
|
# protects codestr: "..." strings
|
|
codestr = _strings_re.sub(lambda m: _reverse_safe_strings_re.sub(lambda n: _reverse_safe_strings[n.group(0)], m.group(0)), codestr)
|
|
|
|
# removes multiple line comments
|
|
codestr = _ml_comment_re.sub('', codestr)
|
|
|
|
# removes inline comments, but not :// (protocol)
|
|
codestr = _sl_comment_re.sub('', codestr)
|
|
|
|
codestr = _safe_strings_re.sub(lambda m: _safe_strings[m.group(0)], codestr)
|
|
|
|
# expand the space in rules
|
|
codestr = _expand_rules_space_re.sub(' {', codestr)
|
|
|
|
# collapse the space in properties blocks
|
|
codestr = _collapse_properties_space_re.sub(r'\1{', codestr)
|
|
|
|
return codestr
|
|
|
|
|
|
class Scss(object):
|
|
def __init__(self,
|
|
scss_vars=None, scss_opts=None, scss_files=None, super_selector=None,
|
|
live_errors=False, library=ALL_BUILTINS_LIBRARY, func_registry=None, search_paths=None):
|
|
|
|
if super_selector:
|
|
self.super_selector = super_selector + ' '
|
|
else:
|
|
self.super_selector = ''
|
|
|
|
self._scss_vars = {}
|
|
if scss_vars:
|
|
calculator = Calculator()
|
|
for var_name, value in scss_vars.items():
|
|
if isinstance(value, six.string_types):
|
|
scss_value = calculator.evaluate_expression(value)
|
|
if scss_value is None:
|
|
# TODO warning?
|
|
scss_value = String.unquoted(value)
|
|
else:
|
|
scss_value = value
|
|
self._scss_vars[var_name] = scss_value
|
|
|
|
self._scss_opts = scss_opts
|
|
self._scss_files = scss_files
|
|
# NOTE: func_registry is backwards-compatibility for only one user and
|
|
# has never existed in a real release
|
|
self._library = func_registry or library
|
|
self._search_paths = search_paths
|
|
|
|
# If true, swallow compile errors and embed them in the output instead
|
|
self.live_errors = live_errors
|
|
|
|
self.reset()
|
|
|
|
def get_scss_constants(self):
|
|
scss_vars = self.root_namespace.variables
|
|
return dict((k, v) for k, v in scss_vars.items() if k and (not k.startswith('$') or k.startswith('$') and k[1].isupper()))
|
|
|
|
def get_scss_vars(self):
|
|
scss_vars = self.root_namespace.variables
|
|
return dict((k, v) for k, v in scss_vars.items() if k and not (not k.startswith('$') or k.startswith('$') and k[1].isupper()))
|
|
|
|
def reset(self, input_scss=None):
|
|
# Initialize
|
|
self.scss_vars = _default_scss_vars.copy()
|
|
if self._scss_vars is not None:
|
|
self.scss_vars.update(self._scss_vars)
|
|
|
|
self.scss_opts = self._scss_opts.copy() if self._scss_opts else {}
|
|
|
|
self.root_namespace = Namespace(variables=self.scss_vars, functions=self._library)
|
|
|
|
# Figure out search paths. Fall back from provided explicitly to
|
|
# defined globally to just searching the current directory
|
|
self.search_paths = ['.']
|
|
if self._search_paths is not None:
|
|
assert not isinstance(self._search_paths, six.string_types), \
|
|
"`search_paths` should be an iterable, not a string"
|
|
self.search_paths.extend(self._search_paths)
|
|
else:
|
|
if config.LOAD_PATHS:
|
|
if isinstance(config.LOAD_PATHS, six.string_types):
|
|
# Back-compat: allow comma-delimited
|
|
self.search_paths.extend(config.LOAD_PATHS.split(','))
|
|
else:
|
|
self.search_paths.extend(config.LOAD_PATHS)
|
|
|
|
self.search_paths.extend(self.scss_opts.get('load_paths', []))
|
|
|
|
self.source_files = []
|
|
self.source_file_index = {}
|
|
if self._scss_files is not None:
|
|
for name, contents in list(self._scss_files.items()):
|
|
if name in self.source_file_index:
|
|
raise KeyError("Duplicate filename %r" % name)
|
|
source_file = SourceFile(name, contents)
|
|
self.source_files.append(source_file)
|
|
self.source_file_index[name] = source_file
|
|
|
|
self.rules = []
|
|
|
|
#@profile
|
|
#@print_timing(2)
|
|
def Compilation(self, scss_string=None, scss_file=None, super_selector=None, filename=None, is_sass=None, line_numbers=True):
|
|
if super_selector:
|
|
self.super_selector = super_selector + ' '
|
|
self.reset()
|
|
|
|
source_file = None
|
|
if scss_string is not None:
|
|
source_file = SourceFile.from_string(scss_string, filename, is_sass, line_numbers)
|
|
elif scss_file is not None:
|
|
source_file = SourceFile.from_filename(scss_file, filename, is_sass, line_numbers)
|
|
|
|
if source_file is not None:
|
|
# Clear the existing list of files
|
|
self.source_files = []
|
|
self.source_file_index = dict()
|
|
|
|
self.source_files.append(source_file)
|
|
self.source_file_index[source_file.filename] = source_file
|
|
|
|
# this will compile and manage rule: child objects inside of a node
|
|
self.parse_children()
|
|
|
|
# this will manage @extends
|
|
self.apply_extends()
|
|
|
|
rules_by_file, css_files = self.parse_properties()
|
|
|
|
all_rules = 0
|
|
all_selectors = 0
|
|
exceeded = ''
|
|
final_cont = ''
|
|
files = len(css_files)
|
|
for source_file in css_files:
|
|
rules = rules_by_file[source_file]
|
|
fcont, total_rules, total_selectors = self.create_css(rules)
|
|
all_rules += total_rules
|
|
all_selectors += total_selectors
|
|
if not exceeded and all_selectors > 4095:
|
|
exceeded = " (IE exceeded!)"
|
|
log.error("Maximum number of supported selectors in Internet Explorer (4095) exceeded!")
|
|
if files > 1 and self.scss_opts.get('debug_info', False):
|
|
if source_file.is_string:
|
|
final_cont += "/* %s %s generated add up to a total of %s %s accumulated%s */\n" % (
|
|
total_selectors,
|
|
'selector' if total_selectors == 1 else 'selectors',
|
|
all_selectors,
|
|
'selector' if all_selectors == 1 else 'selectors',
|
|
exceeded)
|
|
else:
|
|
final_cont += "/* %s %s generated from '%s' add up to a total of %s %s accumulated%s */\n" % (
|
|
total_selectors,
|
|
'selector' if total_selectors == 1 else 'selectors',
|
|
source_file.filename,
|
|
all_selectors,
|
|
'selector' if all_selectors == 1 else 'selectors',
|
|
exceeded)
|
|
final_cont += fcont
|
|
|
|
return final_cont
|
|
|
|
def compile(self, *args, **kwargs):
|
|
try:
|
|
return self.Compilation(*args, **kwargs)
|
|
except SassError as e:
|
|
if self.live_errors:
|
|
# TODO should this setting also capture and display warnings?
|
|
return e.to_css()
|
|
else:
|
|
raise
|
|
|
|
def parse_selectors(self, raw_selectors):
|
|
"""
|
|
Parses out the old xCSS "foo extends bar" syntax.
|
|
|
|
Returns a 2-tuple: a set of selectors, and a set of extended selectors.
|
|
"""
|
|
# Fix tabs and spaces in selectors
|
|
raw_selectors = _spaces_re.sub(' ', raw_selectors)
|
|
|
|
import re
|
|
|
|
from scss.selector import Selector
|
|
|
|
parts = re.split(r'\s+extends\s+', raw_selectors, 1)
|
|
if len(parts) > 1:
|
|
unparsed_selectors, unsplit_parents = parts
|
|
# Multiple `extends` are delimited by `&`
|
|
unparsed_parents = unsplit_parents.split('&')
|
|
else:
|
|
unparsed_selectors, = parts
|
|
unparsed_parents = ()
|
|
|
|
selectors = Selector.parse_many(unparsed_selectors)
|
|
parents = [Selector.parse_one(parent) for parent in unparsed_parents]
|
|
|
|
return selectors, parents
|
|
|
|
@print_timing(3)
|
|
def parse_children(self, scope=None):
|
|
children = []
|
|
root_namespace = self.root_namespace
|
|
for source_file in self.source_files:
|
|
rule = SassRule(
|
|
source_file=source_file,
|
|
|
|
unparsed_contents=source_file.contents,
|
|
namespace=root_namespace,
|
|
options=self.scss_opts,
|
|
)
|
|
self.rules.append(rule)
|
|
children.append(rule)
|
|
|
|
for rule in children:
|
|
self.manage_children(rule, scope)
|
|
|
|
if self.scss_opts.get('warn_unused'):
|
|
for name, file_and_line in root_namespace.unused_imports():
|
|
log.warn("Unused @import: '%s' (%s)", name, file_and_line)
|
|
|
|
@print_timing(4)
|
|
def manage_children(self, rule, scope):
|
|
try:
|
|
self._manage_children_impl(rule, scope)
|
|
except SassReturn:
|
|
raise
|
|
except SassError as e:
|
|
e.add_rule(rule)
|
|
raise
|
|
except Exception as e:
|
|
raise SassError(e, rule=rule)
|
|
|
|
def _manage_children_impl(self, rule, scope):
|
|
calculator = Calculator(rule.namespace)
|
|
|
|
for c_lineno, c_property, c_codestr in locate_blocks(rule.unparsed_contents):
|
|
block = UnparsedBlock(rule, c_lineno, c_property, c_codestr)
|
|
|
|
if block.is_atrule:
|
|
code = block.directive
|
|
code = code.lower()
|
|
if code == '@warn':
|
|
value = calculator.calculate(block.argument)
|
|
log.warn(repr(value))
|
|
elif code == '@print':
|
|
value = calculator.calculate(block.argument)
|
|
sys.stderr.write("%s\n" % value)
|
|
elif code == '@raw':
|
|
value = calculator.calculate(block.argument)
|
|
sys.stderr.write("%s\n" % repr(value))
|
|
elif code == '@dump_context':
|
|
sys.stderr.write("%s\n" % repr(rule.namespace._variables))
|
|
elif code == '@dump_functions':
|
|
sys.stderr.write("%s\n" % repr(rule.namespace._functions))
|
|
elif code == '@dump_mixins':
|
|
sys.stderr.write("%s\n" % repr(rule.namespace._mixins))
|
|
elif code == '@dump_imports':
|
|
sys.stderr.write("%s\n" % repr(rule.namespace._imports))
|
|
elif code == '@dump_options':
|
|
sys.stderr.write("%s\n" % repr(rule.options))
|
|
elif code == '@debug':
|
|
setting = block.argument.strip()
|
|
if setting.lower() in ('1', 'true', 't', 'yes', 'y', 'on'):
|
|
setting = True
|
|
elif setting.lower() in ('0', 'false', 'f', 'no', 'n', 'off', 'undefined'):
|
|
setting = False
|
|
config.DEBUG = setting
|
|
log.info("Debug mode is %s", 'On' if config.DEBUG else 'Off')
|
|
elif code == '@option':
|
|
self._settle_options(rule, scope, block)
|
|
elif code == '@content':
|
|
self._do_content(rule, scope, block)
|
|
elif code == '@import':
|
|
self._do_import(rule, scope, block)
|
|
elif code == '@extend':
|
|
from scss.selector import Selector
|
|
selectors = calculator.apply_vars(block.argument)
|
|
# XXX this no longer handles `&`, which is from xcss
|
|
rule.extends_selectors.extend(Selector.parse_many(selectors))
|
|
#rule.extends_selectors.update(p.strip() for p in selectors.replace(',', '&').split('&'))
|
|
#rule.extends_selectors.discard('')
|
|
elif code == '@return':
|
|
# TODO should assert this only happens within a @function
|
|
ret = calculator.calculate(block.argument)
|
|
raise SassReturn(ret)
|
|
elif code == '@include':
|
|
self._do_include(rule, scope, block)
|
|
elif code in ('@mixin', '@function'):
|
|
self._do_functions(rule, scope, block)
|
|
elif code in ('@if', '@else if'):
|
|
self._do_if(rule, scope, block)
|
|
elif code == '@else':
|
|
self._do_else(rule, scope, block)
|
|
elif code == '@for':
|
|
self._do_for(rule, scope, block)
|
|
elif code == '@each':
|
|
self._do_each(rule, scope, block)
|
|
elif code == '@while':
|
|
self._do_while(rule, scope, block)
|
|
elif code in ('@variables', '@vars'):
|
|
self._get_variables(rule, scope, block)
|
|
elif block.unparsed_contents is None:
|
|
rule.properties.append((block.prop, None))
|
|
elif scope is None: # needs to have no scope to crawl down the nested rules
|
|
self._nest_at_rules(rule, scope, block)
|
|
####################################################################
|
|
# Properties
|
|
elif block.unparsed_contents is None:
|
|
self._get_properties(rule, scope, block)
|
|
# Nested properties
|
|
elif block.is_scope:
|
|
if block.header.unscoped_value:
|
|
# Possibly deal with default unscoped value
|
|
self._get_properties(rule, scope, block)
|
|
|
|
rule.unparsed_contents = block.unparsed_contents
|
|
subscope = (scope or '') + block.header.scope + '-'
|
|
self.manage_children(rule, subscope)
|
|
####################################################################
|
|
# Nested rules
|
|
elif scope is None: # needs to have no scope to crawl down the nested rules
|
|
self._nest_rules(rule, scope, block)
|
|
|
|
@print_timing(10)
|
|
def _settle_options(self, rule, scope, block):
|
|
for option in block.argument.split(','):
|
|
option, value = (option.split(':', 1) + [''])[:2]
|
|
option = option.strip().lower()
|
|
value = value.strip()
|
|
if option:
|
|
if value.lower() in ('1', 'true', 't', 'yes', 'y', 'on'):
|
|
value = True
|
|
elif value.lower() in ('0', 'false', 'f', 'no', 'n', 'off', 'undefined'):
|
|
value = False
|
|
option = option.replace('-', '_')
|
|
if option == 'compress':
|
|
option = 'style'
|
|
log.warn("The option 'compress' is deprecated. Please use 'style' instead.")
|
|
rule.options[option] = value
|
|
|
|
def _get_funct_def(self, rule, calculator, argument):
|
|
funct, lpar, argstr = argument.partition('(')
|
|
funct = calculator.do_glob_math(funct)
|
|
funct = normalize_var(funct.strip())
|
|
argstr = argstr.strip()
|
|
|
|
# Parse arguments with the argspec rule
|
|
if lpar:
|
|
if not argstr.endswith(')'):
|
|
raise SyntaxError("Expected ')', found end of line for %s (%s)" % (funct, rule.file_and_line))
|
|
argstr = argstr[:-1].strip()
|
|
else:
|
|
# Whoops, no parens at all. That's like calling with no arguments.
|
|
argstr = ''
|
|
|
|
argstr = calculator.do_glob_math(argstr)
|
|
argspec_node = calculator.parse_expression(argstr, target='goal_argspec')
|
|
return funct, argspec_node
|
|
|
|
def _populate_namespace_from_call(self, name, callee_namespace, mixin, args, kwargs):
|
|
# Mutation protection
|
|
args = list(args)
|
|
kwargs = dict(kwargs)
|
|
|
|
#m_params = mixin[0]
|
|
#m_defaults = mixin[1]
|
|
#m_codestr = mixin[2]
|
|
pristine_callee_namespace = mixin[3]
|
|
callee_argspec = mixin[4]
|
|
import_key = mixin[5]
|
|
|
|
callee_calculator = Calculator(callee_namespace)
|
|
|
|
# Populate the mixin/function's namespace with its arguments
|
|
for var_name, node in callee_argspec.iter_def_argspec():
|
|
if args:
|
|
# If there are positional arguments left, use the first
|
|
value = args.pop(0)
|
|
elif var_name in kwargs:
|
|
# Try keyword arguments
|
|
value = kwargs.pop(var_name)
|
|
elif node is not None:
|
|
# OK, there's a default argument; try that
|
|
# DEVIATION: this allows argument defaults to refer to earlier
|
|
# argument values
|
|
value = node.evaluate(callee_calculator, divide=True)
|
|
else:
|
|
# TODO this should raise
|
|
value = Undefined()
|
|
|
|
callee_namespace.set_variable(var_name, value, local_only=True)
|
|
|
|
if callee_argspec.slurp:
|
|
# Slurpy var gets whatever is left
|
|
callee_namespace.set_variable(
|
|
callee_argspec.slurp.name,
|
|
List(args, use_comma=True))
|
|
args = []
|
|
elif callee_argspec.inject:
|
|
# Callee namespace gets all the extra kwargs whether declared or
|
|
# not
|
|
for var_name, value in kwargs.items():
|
|
callee_namespace.set_variable(var_name, value, local_only=True)
|
|
kwargs = {}
|
|
|
|
# TODO would be nice to say where the mixin/function came from
|
|
if kwargs:
|
|
raise NameError("%s has no such argument %s" % (name, kwargs.keys()[0]))
|
|
|
|
if args:
|
|
raise NameError("%s received extra arguments: %r" % (name, args))
|
|
|
|
pristine_callee_namespace.use_import(import_key)
|
|
return callee_namespace
|
|
|
|
@print_timing(10)
|
|
def _do_functions(self, rule, scope, block):
|
|
"""
|
|
Implements @mixin and @function
|
|
"""
|
|
if not block.argument:
|
|
raise SyntaxError("%s requires a function name (%s)" % (block.directive, rule.file_and_line))
|
|
|
|
calculator = Calculator(rule.namespace)
|
|
funct, argspec_node = self._get_funct_def(rule, calculator, block.argument)
|
|
|
|
defaults = {}
|
|
new_params = []
|
|
|
|
for var_name, default in argspec_node.iter_def_argspec():
|
|
new_params.append(var_name)
|
|
if default is not None:
|
|
defaults[var_name] = default
|
|
|
|
mixin = [rule.source_file, block.lineno, block.unparsed_contents, rule.namespace, argspec_node, rule.import_key]
|
|
if block.directive == '@function':
|
|
def _call(mixin):
|
|
def __call(namespace, *args, **kwargs):
|
|
source_file = mixin[0]
|
|
lineno = mixin[1]
|
|
m_codestr = mixin[2]
|
|
pristine_callee_namespace = mixin[3]
|
|
callee_namespace = pristine_callee_namespace.derive()
|
|
|
|
# TODO CallOp converts Sass names to Python names, so we
|
|
# have to convert them back to Sass names. would be nice
|
|
# to avoid this back-and-forth somehow
|
|
kwargs = dict(
|
|
(normalize_var('$' + key), value)
|
|
for (key, value) in kwargs.items())
|
|
|
|
self._populate_namespace_from_call(
|
|
"Function {0}".format(funct),
|
|
callee_namespace, mixin, args, kwargs)
|
|
|
|
_rule = SassRule(
|
|
source_file=source_file,
|
|
lineno=lineno,
|
|
unparsed_contents=m_codestr,
|
|
namespace=callee_namespace,
|
|
|
|
# rule
|
|
import_key=rule.import_key,
|
|
options=rule.options,
|
|
properties=rule.properties,
|
|
extends_selectors=rule.extends_selectors,
|
|
ancestry=rule.ancestry,
|
|
nested=rule.nested,
|
|
)
|
|
try:
|
|
self.manage_children(_rule, scope)
|
|
except SassReturn as e:
|
|
return e.retval
|
|
else:
|
|
return Null()
|
|
return __call
|
|
_mixin = _call(mixin)
|
|
_mixin.mixin = mixin
|
|
mixin = _mixin
|
|
|
|
if block.directive == '@mixin':
|
|
add = rule.namespace.set_mixin
|
|
elif block.directive == '@function':
|
|
add = rule.namespace.set_function
|
|
|
|
# Register the mixin for every possible arity it takes
|
|
if argspec_node.slurp or argspec_node.inject:
|
|
add(funct, None, mixin)
|
|
else:
|
|
while len(new_params):
|
|
add(funct, len(new_params), mixin)
|
|
param = new_params.pop()
|
|
if param not in defaults:
|
|
break
|
|
if not new_params:
|
|
add(funct, 0, mixin)
|
|
|
|
@print_timing(10)
|
|
def _do_include(self, rule, scope, block):
|
|
"""
|
|
Implements @include, for @mixins
|
|
"""
|
|
caller_namespace = rule.namespace
|
|
caller_calculator = Calculator(caller_namespace)
|
|
funct, caller_argspec = self._get_funct_def(rule, caller_calculator, block.argument)
|
|
|
|
# Render the passed arguments, using the caller's namespace
|
|
args, kwargs = caller_argspec.evaluate_call_args(caller_calculator)
|
|
|
|
argc = len(args) + len(kwargs)
|
|
try:
|
|
mixin = caller_namespace.mixin(funct, argc)
|
|
except KeyError:
|
|
try:
|
|
# TODO maybe? don't do this, once '...' works
|
|
# Fallback to single parameter:
|
|
mixin = caller_namespace.mixin(funct, 1)
|
|
except KeyError:
|
|
log.error("Mixin not found: %s:%d (%s)", funct, argc, rule.file_and_line, extra={'stack': True})
|
|
return
|
|
else:
|
|
args = [List(args, use_comma=True)]
|
|
# TODO what happens to kwargs?
|
|
|
|
source_file = mixin[0]
|
|
lineno = mixin[1]
|
|
m_codestr = mixin[2]
|
|
pristine_callee_namespace = mixin[3]
|
|
callee_argspec = mixin[4]
|
|
if caller_argspec.inject and callee_argspec.inject:
|
|
# DEVIATION: Pass the ENTIRE local namespace to the mixin (yikes)
|
|
callee_namespace = Namespace.derive_from(
|
|
caller_namespace,
|
|
pristine_callee_namespace)
|
|
else:
|
|
callee_namespace = pristine_callee_namespace.derive()
|
|
|
|
self._populate_namespace_from_call(
|
|
"Mixin {0}".format(funct),
|
|
callee_namespace, mixin, args, kwargs)
|
|
|
|
_rule = SassRule(
|
|
source_file=source_file,
|
|
lineno=lineno,
|
|
unparsed_contents=m_codestr,
|
|
namespace=callee_namespace,
|
|
|
|
# rule
|
|
import_key=rule.import_key,
|
|
options=rule.options,
|
|
properties=rule.properties,
|
|
extends_selectors=rule.extends_selectors,
|
|
ancestry=rule.ancestry,
|
|
nested=rule.nested,
|
|
)
|
|
|
|
_rule.options['@content'] = block.unparsed_contents
|
|
self.manage_children(_rule, scope)
|
|
|
|
@print_timing(10)
|
|
def _do_content(self, rule, scope, block):
|
|
"""
|
|
Implements @content
|
|
"""
|
|
if '@content' not in rule.options:
|
|
log.error("Content string not found for @content (%s)", rule.file_and_line)
|
|
rule.unparsed_contents = rule.options.pop('@content', '')
|
|
self.manage_children(rule, scope)
|
|
|
|
@print_timing(10)
|
|
def _do_import(self, rule, scope, block):
|
|
"""
|
|
Implements @import
|
|
Load and import mixins and functions and rules
|
|
"""
|
|
# Protect against going to prohibited places...
|
|
if any(scary_token in block.argument for scary_token in ('..', '://', 'url(')):
|
|
rule.properties.append((block.prop, None))
|
|
return
|
|
|
|
full_filename = None
|
|
names = block.argument.split(',')
|
|
for name in names:
|
|
name = dequote(name.strip())
|
|
|
|
source_file = None
|
|
full_filename, seen_paths = self._find_import(rule, name)
|
|
|
|
if full_filename is None:
|
|
i_codestr = self._do_magic_import(rule, scope, block)
|
|
|
|
if i_codestr is not None:
|
|
source_file = SourceFile.from_string(i_codestr)
|
|
|
|
elif full_filename in self.source_file_index:
|
|
source_file = self.source_file_index[full_filename]
|
|
|
|
else:
|
|
with open(full_filename) as f:
|
|
source = f.read()
|
|
source_file = SourceFile(
|
|
full_filename,
|
|
source,
|
|
parent_dir=os.path.realpath(os.path.dirname(full_filename)),
|
|
)
|
|
|
|
self.source_files.append(source_file)
|
|
self.source_file_index[full_filename] = source_file
|
|
|
|
if source_file is None:
|
|
load_paths_msg = "\nLoad paths:\n\t%s" % "\n\t".join(seen_paths)
|
|
log.warn("File to import not found or unreadable: '%s' (%s)%s", name, rule.file_and_line, load_paths_msg)
|
|
continue
|
|
|
|
import_key = (name, source_file.parent_dir)
|
|
if rule.namespace.has_import(import_key):
|
|
# If already imported in this scope, skip
|
|
continue
|
|
|
|
_rule = SassRule(
|
|
source_file=source_file,
|
|
lineno=block.lineno,
|
|
import_key=import_key,
|
|
unparsed_contents=source_file.contents,
|
|
|
|
# rule
|
|
options=rule.options,
|
|
properties=rule.properties,
|
|
extends_selectors=rule.extends_selectors,
|
|
ancestry=rule.ancestry,
|
|
namespace=rule.namespace,
|
|
)
|
|
rule.namespace.add_import(import_key, rule.import_key, rule.file_and_line)
|
|
self.manage_children(_rule, scope)
|
|
|
|
def _find_import(self, rule, name):
|
|
"""Find the file referred to by an @import.
|
|
|
|
Takes a name from an @import and returns an absolute path, or None.
|
|
"""
|
|
name, ext = os.path.splitext(name)
|
|
if ext:
|
|
search_exts = [ext]
|
|
else:
|
|
search_exts = ['.scss', '.sass']
|
|
|
|
dirname, name = os.path.split(name)
|
|
|
|
seen_paths = []
|
|
|
|
for path in self.search_paths:
|
|
for basepath in [rule.source_file.parent_dir, '.']:
|
|
full_path = os.path.realpath(os.path.join(basepath, path, dirname))
|
|
|
|
if full_path in seen_paths:
|
|
continue
|
|
seen_paths.append(full_path)
|
|
|
|
for prefix, suffix in product(('_', ''), search_exts):
|
|
full_filename = os.path.join(full_path, prefix + name + suffix)
|
|
if os.path.exists(full_filename):
|
|
return full_filename, seen_paths
|
|
|
|
return None, seen_paths
|
|
|
|
@print_timing(10)
|
|
def _do_magic_import(self, rule, scope, block):
|
|
"""
|
|
Implements @import for sprite-maps
|
|
Imports magic sprite map directories
|
|
"""
|
|
if callable(config.STATIC_ROOT):
|
|
files = sorted(config.STATIC_ROOT(block.argument))
|
|
else:
|
|
glob_path = os.path.join(config.STATIC_ROOT, block.argument)
|
|
files = glob.glob(glob_path)
|
|
files = sorted((file[len(config.STATIC_ROOT):], None) for file in files)
|
|
|
|
if not files:
|
|
return
|
|
|
|
# Build magic context
|
|
map_name = os.path.normpath(os.path.dirname(block.argument)).replace('\\', '_').replace('/', '_')
|
|
kwargs = {}
|
|
|
|
calculator = Calculator(rule.namespace)
|
|
|
|
def setdefault(var, val):
|
|
_var = '$' + map_name + '-' + var
|
|
if _var in rule.context:
|
|
kwargs[var] = calculator.interpolate(rule.context[_var], rule, self._library)
|
|
else:
|
|
rule.context[_var] = val
|
|
kwargs[var] = calculator.interpolate(val, rule, self._library)
|
|
return rule.context[_var]
|
|
|
|
setdefault('sprite-base-class', String('.' + map_name + '-sprite', quotes=None))
|
|
setdefault('sprite-dimensions', Boolean(False))
|
|
position = setdefault('position', Number(0, '%'))
|
|
spacing = setdefault('spacing', Number(0))
|
|
repeat = setdefault('repeat', String('no-repeat', quotes=None))
|
|
names = tuple(os.path.splitext(os.path.basename(file))[0] for file, storage in files)
|
|
for n in names:
|
|
setdefault(n + '-position', position)
|
|
setdefault(n + '-spacing', spacing)
|
|
setdefault(n + '-repeat', repeat)
|
|
rule.context['$' + map_name + '-' + 'sprites'] = sprite_map(block.argument, **kwargs)
|
|
ret = '''
|
|
@import "compass/utilities/sprites/base";
|
|
|
|
// All sprites should extend this class
|
|
// The %(map_name)s-sprite mixin will do so for you.
|
|
#{$%(map_name)s-sprite-base-class} {
|
|
background: $%(map_name)s-sprites;
|
|
}
|
|
|
|
// Use this to set the dimensions of an element
|
|
// based on the size of the original image.
|
|
@mixin %(map_name)s-sprite-dimensions($name) {
|
|
@include sprite-dimensions($%(map_name)s-sprites, $name);
|
|
}
|
|
|
|
// Move the background position to display the sprite.
|
|
@mixin %(map_name)s-sprite-position($name, $offset-x: 0, $offset-y: 0) {
|
|
@include sprite-position($%(map_name)s-sprites, $name, $offset-x, $offset-y);
|
|
}
|
|
|
|
// Extends the sprite base class and set the background position for the desired sprite.
|
|
// It will also apply the image dimensions if $dimensions is true.
|
|
@mixin %(map_name)s-sprite($name, $dimensions: $%(map_name)s-sprite-dimensions, $offset-x: 0, $offset-y: 0) {
|
|
@extend #{$%(map_name)s-sprite-base-class};
|
|
@include sprite($%(map_name)s-sprites, $name, $dimensions, $offset-x, $offset-y);
|
|
}
|
|
|
|
@mixin %(map_name)s-sprites($sprite-names, $dimensions: $%(map_name)s-sprite-dimensions) {
|
|
@include sprites($%(map_name)s-sprites, $sprite-names, $%(map_name)s-sprite-base-class, $dimensions);
|
|
}
|
|
|
|
// Generates a class for each sprited image.
|
|
@mixin all-%(map_name)s-sprites($dimensions: $%(map_name)s-sprite-dimensions) {
|
|
@include %(map_name)s-sprites(%(sprites)s, $dimensions);
|
|
}
|
|
''' % {'map_name': map_name, 'sprites': ' '.join(names)}
|
|
return ret
|
|
|
|
@print_timing(10)
|
|
def _do_if(self, rule, scope, block):
|
|
"""
|
|
Implements @if and @else if
|
|
"""
|
|
# "@if" indicates whether any kind of `if` since the last `@else` has
|
|
# succeeded, in which case `@else if` should be skipped
|
|
if block.directive != '@if':
|
|
if '@if' not in rule.options:
|
|
raise SyntaxError("@else with no @if (%s)" % (rule.file_and_line,))
|
|
if rule.options['@if']:
|
|
# Last @if succeeded; stop here
|
|
return
|
|
|
|
calculator = Calculator(rule.namespace)
|
|
condition = calculator.calculate(block.argument)
|
|
if condition:
|
|
inner_rule = rule.copy()
|
|
inner_rule.unparsed_contents = block.unparsed_contents
|
|
if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation
|
|
# DEVIATION: Allow not creating a new namespace
|
|
inner_rule.namespace = rule.namespace
|
|
self.manage_children(inner_rule, scope)
|
|
rule.options['@if'] = condition
|
|
|
|
@print_timing(10)
|
|
def _do_else(self, rule, scope, block):
|
|
"""
|
|
Implements @else
|
|
"""
|
|
if '@if' not in rule.options:
|
|
log.error("@else with no @if (%s)", rule.file_and_line)
|
|
val = rule.options.pop('@if', True)
|
|
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
|
|
self.manage_children(inner_rule, scope)
|
|
|
|
@print_timing(10)
|
|
def _do_for(self, rule, scope, block):
|
|
"""
|
|
Implements @for
|
|
"""
|
|
var, _, name = block.argument.partition(' from ')
|
|
frm, _, through = name.partition(' through ')
|
|
if not through:
|
|
frm, _, through = frm.partition(' to ')
|
|
calculator = Calculator(rule.namespace)
|
|
frm = calculator.calculate(frm)
|
|
through = calculator.calculate(through)
|
|
try:
|
|
frm = int(float(frm))
|
|
through = int(float(through))
|
|
except ValueError:
|
|
return
|
|
|
|
if frm > through:
|
|
# DEVIATION: allow reversed '@for .. from .. through' (same as enumerate() and range())
|
|
frm, through = through, frm
|
|
rev = reversed
|
|
else:
|
|
rev = lambda x: x
|
|
var = var.strip()
|
|
var = calculator.do_glob_math(var)
|
|
var = normalize_var(var)
|
|
|
|
inner_rule = rule.copy()
|
|
inner_rule.unparsed_contents = block.unparsed_contents
|
|
if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation
|
|
# DEVIATION: Allow not creating a new namespace
|
|
inner_rule.namespace = rule.namespace
|
|
|
|
for i in rev(range(frm, through + 1)):
|
|
inner_rule.namespace.set_variable(var, Number(i))
|
|
self.manage_children(inner_rule, scope)
|
|
|
|
@print_timing(10)
|
|
def _do_each(self, rule, scope, block):
|
|
"""
|
|
Implements @each
|
|
"""
|
|
varstring, _, valuestring = block.argument.partition(' in ')
|
|
calculator = Calculator(rule.namespace)
|
|
values = calculator.calculate(valuestring)
|
|
if not values:
|
|
return
|
|
|
|
varlist = varstring.split(",")
|
|
varlist = [
|
|
normalize_var(calculator.do_glob_math(var.strip()))
|
|
for var in varlist
|
|
]
|
|
|
|
inner_rule = rule.copy()
|
|
inner_rule.unparsed_contents = block.unparsed_contents
|
|
if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation
|
|
# DEVIATION: Allow not creating a new namespace
|
|
inner_rule.namespace = rule.namespace
|
|
|
|
for v in List.from_maybe(values):
|
|
v = List.from_maybe(v)
|
|
for i, var in enumerate(varlist):
|
|
if i >= len(v):
|
|
value = Null()
|
|
else:
|
|
value = v[i]
|
|
inner_rule.namespace.set_variable(var, value)
|
|
self.manage_children(inner_rule, scope)
|
|
|
|
@print_timing(10)
|
|
def _do_while(self, rule, scope, block):
|
|
"""
|
|
Implements @while
|
|
"""
|
|
calculator = Calculator(rule.namespace)
|
|
first_condition = condition = calculator.calculate(block.argument)
|
|
while condition:
|
|
inner_rule = rule.copy()
|
|
inner_rule.unparsed_contents = block.unparsed_contents
|
|
if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation
|
|
# DEVIATION: Allow not creating a new namespace
|
|
inner_rule.namespace = rule.namespace
|
|
self.manage_children(inner_rule, scope)
|
|
condition = calculator.calculate(block.argument)
|
|
rule.options['@if'] = first_condition
|
|
|
|
@print_timing(10)
|
|
def _get_variables(self, rule, scope, block):
|
|
"""
|
|
Implements @variables and @vars
|
|
"""
|
|
_rule = rule.copy()
|
|
_rule.unparsed_contents = block.unparsed_contents
|
|
_rule.namespace = rule.namespace
|
|
_rule.properties = {}
|
|
self.manage_children(_rule, scope)
|
|
for name, value in _rule.properties.items():
|
|
rule.namespace.set_variable(name, value)
|
|
|
|
@print_timing(10)
|
|
def _get_properties(self, rule, scope, block):
|
|
"""
|
|
Implements properties and variables extraction and assignment
|
|
"""
|
|
prop, raw_value = (_prop_split_re.split(block.prop, 1) + [None])[:2]
|
|
try:
|
|
is_var = (block.prop[len(prop)] == '=')
|
|
except IndexError:
|
|
is_var = False
|
|
calculator = Calculator(rule.namespace)
|
|
prop = prop.strip()
|
|
prop = calculator.do_glob_math(prop)
|
|
if not prop:
|
|
return
|
|
|
|
# Parse the value and determine whether it's a default assignment
|
|
is_default = False
|
|
if raw_value is not None:
|
|
raw_value = raw_value.strip()
|
|
if prop.startswith('$'):
|
|
raw_value, subs = re.subn(r'(?i)\s+!default\Z', '', raw_value)
|
|
if subs:
|
|
is_default = True
|
|
|
|
_prop = (scope or '') + prop
|
|
if is_var or prop.startswith('$') and raw_value is not None:
|
|
# Variable assignment
|
|
_prop = normalize_var(_prop)
|
|
try:
|
|
existing_value = rule.namespace.variable(_prop)
|
|
except KeyError:
|
|
existing_value = None
|
|
|
|
is_defined = existing_value is not None and not existing_value.is_null
|
|
if is_default and is_defined:
|
|
pass
|
|
else:
|
|
if is_defined and prop.startswith('$') and prop[1].isupper():
|
|
log.warn("Constant %r redefined", prop)
|
|
|
|
# Variable assignment is an expression, so it always performs
|
|
# real division
|
|
value = calculator.calculate(raw_value, divide=True)
|
|
rule.namespace.set_variable(_prop, value)
|
|
else:
|
|
# Regular property destined for output
|
|
_prop = calculator.apply_vars(_prop)
|
|
if raw_value is None:
|
|
value = None
|
|
else:
|
|
value = calculator.calculate(raw_value)
|
|
|
|
if value is None:
|
|
pass
|
|
elif isinstance(value, six.string_types):
|
|
# TODO kill this branch
|
|
pass
|
|
else:
|
|
style = self.scss_opts.get('style', config.STYLE)
|
|
compress = style in (True, 'compressed')
|
|
value = value.render(compress=compress)
|
|
|
|
rule.properties.append((_prop, value))
|
|
|
|
@print_timing(10)
|
|
def _nest_at_rules(self, rule, scope, block):
|
|
"""
|
|
Implements @-blocks
|
|
"""
|
|
# Interpolate the current block
|
|
# TODO this seems like it should be done in the block header. and more
|
|
# generally?
|
|
calculator = Calculator(rule.namespace)
|
|
block.header.argument = calculator.apply_vars(block.header.argument)
|
|
|
|
# TODO merge into RuleAncestry
|
|
new_ancestry = list(rule.ancestry.headers)
|
|
if block.directive == '@media' and new_ancestry:
|
|
for i, header in reversed(list(enumerate(new_ancestry))):
|
|
if header.is_selector:
|
|
continue
|
|
elif header.directive == '@media':
|
|
from scss.rule import BlockAtRuleHeader
|
|
new_ancestry[i] = BlockAtRuleHeader(
|
|
'@media',
|
|
"%s and %s" % (header.argument, block.argument))
|
|
break
|
|
else:
|
|
new_ancestry.insert(i, block.header)
|
|
else:
|
|
new_ancestry.insert(0, block.header)
|
|
else:
|
|
new_ancestry.append(block.header)
|
|
|
|
from scss.rule import RuleAncestry
|
|
rule.descendants += 1
|
|
new_rule = SassRule(
|
|
source_file=rule.source_file,
|
|
import_key=rule.import_key,
|
|
lineno=block.lineno,
|
|
unparsed_contents=block.unparsed_contents,
|
|
|
|
options=rule.options.copy(),
|
|
#properties
|
|
#extends_selectors
|
|
ancestry=RuleAncestry(new_ancestry),
|
|
|
|
namespace=rule.namespace.derive(),
|
|
nested=rule.nested + 1,
|
|
)
|
|
self.rules.append(new_rule)
|
|
rule.namespace.use_import(rule.import_key)
|
|
self.manage_children(new_rule, scope)
|
|
|
|
if new_rule.options.get('warn_unused'):
|
|
for name, file_and_line in new_rule.namespace.unused_imports():
|
|
log.warn("Unused @import: '%s' (%s)", name, file_and_line)
|
|
|
|
@print_timing(10)
|
|
def _nest_rules(self, rule, scope, block):
|
|
"""
|
|
Implements Nested CSS rules
|
|
"""
|
|
calculator = Calculator(rule.namespace)
|
|
raw_selectors = calculator.do_glob_math(block.prop)
|
|
# DEVIATION: ruby sass doesn't support bare variables in selectors
|
|
raw_selectors = calculator.apply_vars(raw_selectors)
|
|
c_selectors, c_parents = self.parse_selectors(raw_selectors)
|
|
|
|
new_ancestry = rule.ancestry.with_nested_selectors(c_selectors)
|
|
|
|
rule.descendants += 1
|
|
new_rule = SassRule(
|
|
source_file=rule.source_file,
|
|
import_key=rule.import_key,
|
|
lineno=block.lineno,
|
|
unparsed_contents=block.unparsed_contents,
|
|
|
|
options=rule.options.copy(),
|
|
#properties
|
|
extends_selectors=c_parents,
|
|
ancestry=new_ancestry,
|
|
|
|
namespace=rule.namespace.derive(),
|
|
nested=rule.nested + 1,
|
|
)
|
|
self.rules.append(new_rule)
|
|
rule.namespace.use_import(rule.import_key)
|
|
self.manage_children(new_rule, scope)
|
|
|
|
if new_rule.options.get('warn_unused'):
|
|
for name, file_and_line in new_rule.namespace.unused_imports():
|
|
log.warn("Unused @import: '%s' (%s)", name, file_and_line)
|
|
|
|
@print_timing(3)
|
|
def apply_extends(self):
|
|
"""Run through the given rules and translate all the pending @extends
|
|
declarations into real selectors on parent rules.
|
|
|
|
The list is modified in-place and also sorted in dependency order.
|
|
"""
|
|
# Game plan: for each rule that has an @extend, add its selectors to
|
|
# every rule that matches that @extend.
|
|
# First, rig a way to find arbitrary selectors quickly. Most selectors
|
|
# revolve around elements, classes, and IDs, so parse those out and use
|
|
# them as a rough key. Ignore order and duplication for now.
|
|
key_to_selectors = defaultdict(set)
|
|
selector_to_rules = defaultdict(list)
|
|
# DEVIATION: These are used to rearrange rules in dependency order, so
|
|
# an @extended parent appears in the output before a child. Sass does
|
|
# not do this, and the results may be unexpected. Pending removal.
|
|
rule_order = dict()
|
|
rule_dependencies = dict()
|
|
order = 0
|
|
for rule in self.rules:
|
|
rule_order[rule] = order
|
|
# Rules are ultimately sorted by the earliest rule they must
|
|
# *precede*, so every rule should "depend" on the next one
|
|
rule_dependencies[rule] = [order + 1]
|
|
order += 1
|
|
|
|
for selector in rule.selectors:
|
|
for key in selector.lookup_key():
|
|
key_to_selectors[key].add(selector)
|
|
selector_to_rules[selector].append(rule)
|
|
|
|
# Now go through all the rules with an @extends and find their parent
|
|
# rules.
|
|
for rule in self.rules:
|
|
for selector in rule.extends_selectors:
|
|
# This is a little dirty. intersection isn't a class method.
|
|
# Don't think about it too much.
|
|
candidates = set.intersection(*(
|
|
key_to_selectors[key] for key in selector.lookup_key()))
|
|
extendable_selectors = [
|
|
candidate for candidate in candidates
|
|
if candidate.is_superset_of(selector)]
|
|
|
|
if not extendable_selectors:
|
|
log.warn(
|
|
"Can't find any matching rules to extend: %s"
|
|
% selector.render())
|
|
continue
|
|
|
|
# Armed with a set of selectors that this rule can extend, do
|
|
# some substitution and modify the appropriate parent rules
|
|
for extendable_selector in extendable_selectors:
|
|
# list() shields us from problems mutating the list within
|
|
# this loop, which can happen in the case of @extend loops
|
|
parent_rules = list(selector_to_rules[extendable_selector])
|
|
for parent_rule in parent_rules:
|
|
if parent_rule is rule:
|
|
# Don't extend oneself
|
|
continue
|
|
|
|
more_parent_selectors = []
|
|
|
|
for rule_selector in rule.selectors:
|
|
more_parent_selectors.extend(
|
|
extendable_selector.substitute(
|
|
selector, rule_selector))
|
|
|
|
for parent in more_parent_selectors:
|
|
# Update indices, in case any later rules try to
|
|
# extend this one
|
|
for key in parent.lookup_key():
|
|
key_to_selectors[key].add(parent)
|
|
# TODO this could lead to duplicates? maybe should
|
|
# be a set too
|
|
selector_to_rules[parent].append(parent_rule)
|
|
|
|
parent_rule.ancestry = (
|
|
parent_rule.ancestry.with_more_selectors(
|
|
more_parent_selectors))
|
|
rule_dependencies[parent_rule].append(rule_order[rule])
|
|
|
|
self.rules.sort(key=lambda rule: min(rule_dependencies[rule]))
|
|
|
|
@print_timing(3)
|
|
def parse_properties(self):
|
|
css_files = []
|
|
seen_files = set()
|
|
rules_by_file = {}
|
|
|
|
for rule in self.rules:
|
|
source_file = rule.source_file
|
|
rules_by_file.setdefault(source_file, []).append(rule)
|
|
|
|
if rule.is_empty:
|
|
continue
|
|
|
|
if source_file not in seen_files:
|
|
seen_files.add(source_file)
|
|
css_files.append(source_file)
|
|
|
|
return rules_by_file, css_files
|
|
|
|
@print_timing(3)
|
|
def create_css(self, rules):
|
|
"""
|
|
Generate the final CSS string
|
|
"""
|
|
style = self.scss_opts.get('style', config.STYLE)
|
|
debug_info = self.scss_opts.get('debug_info', False)
|
|
|
|
if style == 'legacy' or style is False:
|
|
sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', False, '', '\n', '\n', '\n', debug_info
|
|
elif style == 'compressed' or style is True:
|
|
sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = False, '', '', False, '', '', '', '', False
|
|
elif style == 'compact':
|
|
sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', '', False, '\n', ' ', '\n', ' ', debug_info
|
|
elif style == 'expanded':
|
|
sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', False, '\n', '\n', '\n', '\n', debug_info
|
|
else: # if style == 'nested':
|
|
sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', True, '\n', '\n', '\n', ' ', debug_info
|
|
|
|
return self._create_css(rules, sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg)
|
|
|
|
def _textwrap(self, txt, width=70):
|
|
if not hasattr(self, '_textwrap_wordsep_re'):
|
|
self._textwrap_wordsep_re = re.compile(r'(?<=,)\s+')
|
|
self._textwrap_strings_re = re.compile(r'''(["'])(?:(?!\1)[^\\]|\\.)*\1''')
|
|
|
|
# First, remove commas from anything within strings (marking commas as \0):
|
|
def _repl(m):
|
|
ori = m.group(0)
|
|
fin = ori.replace(',', '\0')
|
|
if ori != fin:
|
|
subs[fin] = ori
|
|
return fin
|
|
subs = {}
|
|
txt = self._textwrap_strings_re.sub(_repl, txt)
|
|
|
|
# Mark split points for word separators using (marking spaces with \1):
|
|
txt = self._textwrap_wordsep_re.sub('\1', txt)
|
|
|
|
# Replace all the strings back:
|
|
for fin, ori in subs.items():
|
|
txt = txt.replace(fin, ori)
|
|
|
|
# Split in chunks:
|
|
chunks = txt.split('\1')
|
|
|
|
# Break in lines of at most long_width width appending chunks:
|
|
ln = ''
|
|
lines = []
|
|
long_width = int(width * 1.2)
|
|
for chunk in chunks:
|
|
_ln = ln + ' ' if ln else ''
|
|
_ln += chunk
|
|
if len(ln) >= width or len(_ln) >= long_width:
|
|
if ln:
|
|
lines.append(ln)
|
|
_ln = chunk
|
|
ln = _ln
|
|
if ln:
|
|
lines.append(ln)
|
|
|
|
return lines
|
|
|
|
def _create_css(self, rules, sc=True, sp=' ', tb=' ', nst=True, srnl='\n', nl='\n', rnl='\n', lnl='', debug_info=False):
|
|
skip_selectors = False
|
|
|
|
prev_ancestry_headers = []
|
|
|
|
total_rules = 0
|
|
total_selectors = 0
|
|
|
|
result = ''
|
|
dangling_property = False
|
|
separate = False
|
|
nesting = current_nesting = last_nesting = -1 if nst else 0
|
|
nesting_stack = []
|
|
for rule in rules:
|
|
nested = rule.nested
|
|
if nested <= 1:
|
|
separate = True
|
|
|
|
if nst:
|
|
last_nesting = current_nesting
|
|
current_nesting = nested
|
|
|
|
delta_nesting = current_nesting - last_nesting
|
|
if delta_nesting > 0:
|
|
nesting_stack += [nesting] * delta_nesting
|
|
elif delta_nesting < 0:
|
|
nesting_stack = nesting_stack[:delta_nesting]
|
|
nesting = nesting_stack[-1]
|
|
|
|
if rule.is_empty:
|
|
continue
|
|
|
|
if nst:
|
|
nesting += 1
|
|
|
|
ancestry = rule.ancestry
|
|
ancestry_len = len(ancestry)
|
|
|
|
first_mismatch = 0
|
|
for i, (old_header, new_header) in enumerate(zip(prev_ancestry_headers, ancestry.headers)):
|
|
if old_header != new_header:
|
|
first_mismatch = i
|
|
break
|
|
|
|
# When sc is False, sets of properties are printed without a
|
|
# trailing semicolon. If the previous block isn't being closed,
|
|
# that trailing semicolon needs adding in to separate the last
|
|
# property from the next rule.
|
|
if not sc and dangling_property and first_mismatch >= len(prev_ancestry_headers):
|
|
result += ';'
|
|
|
|
# Close blocks and outdent as necessary
|
|
for i in range(len(prev_ancestry_headers), first_mismatch, -1):
|
|
result += tb * (i - 1) + '}' + rnl
|
|
|
|
# Open new blocks as necessary
|
|
for i in range(first_mismatch, ancestry_len):
|
|
header = ancestry.headers[i]
|
|
|
|
if separate:
|
|
if result:
|
|
result += srnl
|
|
separate = False
|
|
if debug_info:
|
|
if not rule.source_file.is_string:
|
|
filename = rule.source_file.filename
|
|
lineno = str(rule.lineno)
|
|
if debug_info == 'comments':
|
|
result += tb * (i + nesting) + "/* file: %s, line: %s */" % (filename, lineno) + nl
|
|
else:
|
|
filename = _escape_chars_re.sub(r'\\\1', filename)
|
|
result += tb * (i + nesting) + "@media -sass-debug-info{filename{font-family:file\:\/\/%s}line{font-family:\\00003%s}}" % (filename, lineno) + nl
|
|
|
|
if header.is_selector:
|
|
header_string = header.render(sep=',' + sp, super_selector=self.super_selector)
|
|
if nl:
|
|
header_string = (nl + tb * (i + nesting)).join(self._textwrap(header_string))
|
|
else:
|
|
header_string = header.render()
|
|
result += tb * (i + nesting) + header_string + sp + '{' + nl
|
|
|
|
total_rules += 1
|
|
if header.is_selector:
|
|
total_selectors += 1
|
|
|
|
prev_ancestry_headers = ancestry.headers
|
|
dangling_property = False
|
|
|
|
if not skip_selectors:
|
|
result += self._print_properties(rule.properties, sc, sp, tb * (ancestry_len + nesting), nl, lnl)
|
|
dangling_property = True
|
|
|
|
# Close all remaining blocks
|
|
for i in reversed(range(len(prev_ancestry_headers))):
|
|
result += tb * i + '}' + rnl
|
|
|
|
return (result, total_rules, total_selectors)
|
|
|
|
def _print_properties(self, properties, sc=True, sp=' ', tb='', nl='\n', lnl=' '):
|
|
result = ''
|
|
last_prop_index = len(properties) - 1
|
|
for i, (name, value) in enumerate(properties):
|
|
if value is None:
|
|
prop = name
|
|
elif value:
|
|
if nl:
|
|
value = (nl + tb + tb).join(self._textwrap(value))
|
|
prop = name + ':' + sp + value
|
|
else:
|
|
# Empty string means there's supposed to be a value but it
|
|
# evaluated to nothing; skip this
|
|
# TODO interacts poorly with last_prop_index
|
|
continue
|
|
|
|
if i == last_prop_index:
|
|
if sc:
|
|
result += tb + prop + ';' + lnl
|
|
else:
|
|
result += tb + prop + lnl
|
|
else:
|
|
result += tb + prop + ';' + nl
|
|
return result
|
|
|
|
|
|
# TODO: this should inherit from SassError, but can't, because that assumes
|
|
# it's wrapping another error. fix this with the exception hierarchy
|
|
class SassReturn(Exception):
|
|
"""Special control-flow exception used to hop up the stack from a Sass
|
|
function's ``@return``.
|
|
"""
|
|
def __init__(self, retval):
|
|
self.retval = retval
|
|
Exception.__init__(self)
|
|
|
|
def __str__(self):
|
|
return "Returning {0!r}".format(self.retval)
|