diff options
author | Eevee (Alex Munroe) <eevee.git@veekun.com> | 2014-08-24 14:17:00 -0700 |
---|---|---|
committer | Eevee (Alex Munroe) <eevee.git@veekun.com> | 2014-08-24 14:17:00 -0700 |
commit | 7cd1e2909d805f8e878fee2b6596d40dbd780f6f (patch) | |
tree | 3f3af04f1f3a5720a6d097eff8343fb27401a589 | |
parent | 55a07b764d305fb7b168c07d31d92a49faf92f31 (diff) | |
parent | fa72d19af800a9819464d4b56824f49256ff7af9 (diff) | |
download | pyscss-7cd1e2909d805f8e878fee2b6596d40dbd780f6f.tar.gz |
Merge branch 'two-point-oh-refactor'
Splits the compiler from a compilation run, cleans up the API a lot, and
extracts the old API into a legacy compatibility class. With that
breath of fresh air, I fixed a few bugs and started throwing warnings
for deprecated behavior, too.
I expected this to be a long-running catastrophic branch (hence the
name), but it actually turned out not too hard. Now we have a nice base
for 1.3.
-rw-r--r-- | scss/__init__.py | 1657 | ||||
-rw-r--r-- | scss/compiler.py | 1531 | ||||
-rw-r--r-- | scss/legacy.py | 178 | ||||
-rw-r--r-- | scss/namespace.py | 217 | ||||
-rw-r--r-- | scss/rule.py | 217 | ||||
-rw-r--r-- | scss/source.py | 278 | ||||
-rw-r--r-- | scss/tests/files/bugs/extend-selector-order.css | 7 | ||||
-rw-r--r-- | scss/tests/files/bugs/extend-selector-order.scss | 17 | ||||
-rw-r--r-- | scss/tests/files/bugs/for-to-vs-through.css | 14 | ||||
-rw-r--r-- | scss/tests/files/bugs/for-to-vs-through.scss | 12 | ||||
-rw-r--r-- | scss/tests/files/original-doctests/007-extends-3.css | 8 | ||||
-rw-r--r-- | scss/tests/test_misc.py | 8 | ||||
-rw-r--r-- | scss/tool.py | 71 |
13 files changed, 2318 insertions, 1897 deletions
diff --git a/scss/__init__.py b/scss/__init__.py index b08da1b..be054ee 100644 --- a/scss/__init__.py +++ b/scss/__init__.py @@ -47,1662 +47,17 @@ __author__ = AUTHOR + ' <' + AUTHOR_EMAIL + '>' __license__ = LICENSE -from collections import defaultdict -import glob -from itertools import product import logging -import warnings -import os.path -import re -import sys - -import six - -from scss import config -from scss.cssdefs import ( - determine_encoding, - _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 -try: - from scss._speedups import locate_blocks -except ImportError: - from scss._native import locate_blocks - -################################################################################ - -_default_rule_re = re.compile(r'(?i)\s+!default\Z') -_xcss_extends_re = re.compile(r'\s+extends\s+') - -_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, encoding=None, parent_dir=None, is_string=False, is_sass=None, line_numbers=True, line_strip=True): - filename = os.path.realpath(filename) - if parent_dir is None: - parent_dir = os.path.dirname(filename) - else: - parent_dir = os.path.realpath(parent_dir) - filename = os.path.basename(filename) - - self.filename = filename - self.parent_dir = parent_dir - - self.encoding = encoding - 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.is_string = is_string - - def __repr__(self): - return "<SourceFile '%s' at 0x%x>" % ( - self.filename, - id(self), - ) - - @property - def full_filename(self): - if self.is_string: - return self.filename - return os.path.join(self.parent_dir, self.filename) - - @classmethod - def from_filename(cls, fn, filename=None, parent_dir=None, **kwargs): - # Open in binary mode so we can reliably detect the encoding - with open(fn, 'rb') as f: - return cls.from_file(f, filename=filename, parent_dir=parent_dir, **kwargs) - - @classmethod - def from_file(cls, f, filename=None, parent_dir=None, **kwargs): - contents = f.read() - encoding = determine_encoding(contents) - if isinstance(contents, six.binary_type): - contents = contents.decode(encoding) - - if filename is None: - filename = getattr(f, 'name', None) - - return cls(filename, contents, encoding=encoding, parent_dir=parent_dir, **kwargs) - - @classmethod - def from_string(cls, string, filename=None, parent_dir=None, is_sass=None, line_numbers=True): - if isinstance(string, six.text_type): - # Already decoded; we don't know what encoding to use for output, - # though, so still check for a @charset. - encoding = determine_encoding(string) - elif isinstance(string, six.binary_type): - encoding = determine_encoding(string) - string = string.decode(encoding) - else: - raise TypeError("Expected a string, got {0!r}".format(string)) - - if filename is None: - filename = "<string %r...>" % string[:50] - is_string = True - else: - # Must have come from a file at some point - is_string = False - - return cls(filename, string, parent_dir=parent_dir, is_string=is_string, 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() - - state['prev_line'] = line - state['prev_line_no'] = line_no - - if 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() - - state['prev_indent'] = indent - state['prev_line'] = line - state['prev_line_no'] = line_no - - if 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.from_string(contents, filename=name) - 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, source_files=None, super_selector=None, filename=None, is_sass=None, line_numbers=True): - # TODO this signature is totally wacky; it should just take a list of - # source files - if super_selector: - self.super_selector = super_selector + ' ' - self.reset() - - source_file = None - if source_files is not None: - self.source_files = source_files - elif scss_string is not None: - source_file = SourceFile.from_string(scss_string, filename=filename, is_sass=is_sass, line_numbers=line_numbers) - elif scss_file is not None: - source_file = SourceFile.from_filename(scss_file, filename=filename, is_sass=is_sass, line_numbers=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 = _xcss_extends_re.split(raw_selectors, 1) # handle old xCSS extends - 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) - - #################################################################### - # At (@) blocks - if block.is_atrule: - code = block.directive - code = '_at_' + code.lower().replace(' ', '_')[1:] - try: - getattr(self, code)(calculator, rule, scope, block) - except AttributeError: - if 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) - - def _at_warn(self, calculator, rule, scope, block): - """ - Implements @warn - """ - value = calculator.calculate(block.argument) - log.warn(repr(value)) - - def _at_print(self, calculator, rule, scope, block): - """ - Implements @print - """ - value = calculator.calculate(block.argument) - sys.stderr.write("%s\n" % value) - - def _at_raw(self, calculator, rule, scope, block): - """ - Implements @raw - """ - value = calculator.calculate(block.argument) - sys.stderr.write("%s\n" % repr(value)) - - def _at_dump_context(self, calculator, rule, scope, block): - """ - Implements @dump_context - """ - sys.stderr.write("%s\n" % repr(rule.namespace._variables)) - - def _at_dump_functions(self, calculator, rule, scope, block): - """ - Implements @dump_functions - """ - sys.stderr.write("%s\n" % repr(rule.namespace._functions)) - - def _at_dump_mixins(self, calculator, rule, scope, block): - """ - Implements @dump_mixins - """ - sys.stderr.write("%s\n" % repr(rule.namespace._mixins)) - - def _at_dump_imports(self, calculator, rule, scope, block): - """ - Implements @dump_imports - """ - sys.stderr.write("%s\n" % repr(rule.namespace._imports)) - - def _at_dump_options(self, calculator, rule, scope, block): - """ - Implements @dump_options - """ - sys.stderr.write("%s\n" % repr(rule.options)) - - def _at_debug(self, calculator, rule, scope, block): - """ - Implements @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') - - def _at_pdb(self, calculator, rule, scope, block): - """ - Implements @pdb - """ - try: - import ipdb as pdb - except ImportError: - import pdb - pdb.set_trace() - - def _at_extend(self, calculator, rule, scope, block): - """ - Implements @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('') - - def _at_return(self, calculator, rule, scope, block): - """ - Implements @return - """ - # TODO should assert this only happens within a @function - ret = calculator.calculate(block.argument) - raise SassReturn(ret) - - # @print_timing(10) - def _at_option(self, calculator, rule, scope, block): - """ - Implements @option - """ - 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 _at_function(self, calculator, 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)) - - 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) - _at_mixin = _at_function - - # @print_timing(10) - def _at_include(self, calculator, 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( - # These must be file and line in which the @include occurs - source_file=rule.source_file, - lineno=rule.lineno, - - # These must be file and line in which the @mixin was defined - from_source_file=source_file, - from_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 _at_content(self, calculator, 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 _at_import(self, calculator, rule, scope, block): - """ - Implements @import - Load and import mixins and functions and rules - """ - full_filename = None - names = block.argument.split(',') - for name in names: - name = dequote(name.strip()) - - # Protect against going to prohibited places... - if any(scary_token in name for scary_token in ('..', '://', 'url(')): - rule.properties.append((block.prop, None)) - warnings.warn("Ignored import: %s" % name, RuntimeWarning) - continue - - source_file = None - full_filename, seen_paths = self._find_import(rule, name, skip=rule.source_file.full_filename) - - if full_filename is None: - i_codestr = self._at_magic_import(calculator, 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: - source_file = SourceFile.from_filename(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) - raise IOError("File to import not found or unreadable: '%s' (%s)%s" % (name, rule.file_and_line, load_paths_msg)) - - 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, - import_key=import_key, - lineno=block.lineno, - 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, skip=None): - """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): - if full_filename == skip: - continue - return full_filename, seen_paths - - return None, seen_paths - - # @print_timing(10) - def _at_magic_import(self, calculator, 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 = {} - - 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 _at_if(self, calculator, 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 - - 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 - _at_else_if = _at_if - - # @print_timing(10) - def _at_else(self, calculator, 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 _at_for(self, calculator, rule, scope, block): - """ - Implements @for - """ - var, _, name = block.argument.partition(' from ') - frm, _, through = name.partition(' through ') - if not through: - frm, _, through = frm.partition(' to ') - 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 _at_each(self, calculator, rule, scope, block): - """ - Implements @each - """ - varstring, _, valuestring = block.argument.partition(' in ') - values = calculator.calculate(valuestring) - if not values: - return - - varlist = [ - normalize_var(calculator.do_glob_math(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(varlist) > 1 - if not varlist[-1]: - varlist.pop() - - 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): - if unpack: - 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) - else: - inner_rule.namespace.set_variable(varlist[0], v) - self.manage_children(inner_rule, scope) - - # @print_timing(10) - def _at_while(self, calculator, rule, scope, block): - """ - Implements @while - """ - 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 _at_variables(self, calculator, 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) - _at_vars = _at_variables - - # @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 = _default_rule_re.subn('', raw_value) # handle !default - 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 - """ - # TODO handle @charset, probably? - # Interpolate the current block - # TODO this seems like it should be done in the block header. and more - # generally? - calculator = Calculator(rule.namespace) - if block.header.argument: - 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]) - - # clean up placeholder only rules - self.rules = [rule for rule in self.rules if not rule.is_pure_placeholder] - 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: - def _print_debug_info(filename, 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 - return result - - if rule.lineno and rule.source_file and not rule.source_file.is_string: - result += _print_debug_info(rule.source_file.filename, rule.lineno) - - if rule.from_lineno and rule.from_source_file and not rule.from_source_file.is_string: - result += _print_debug_info(rule.from_source_file.filename, rule.from_lineno) - - 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 +# Helpful re-exports +from scss.compiler import Compiler +# Backwards compatibility +from scss.legacy import Scss +# TODO surely there are others. what do our own django docs say...? -# 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) +__all__ = ['Compiler'] diff --git a/scss/compiler.py b/scss/compiler.py new file mode 100644 index 0000000..0d0a62f --- /dev/null +++ b/scss/compiler.py @@ -0,0 +1,1531 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import division + +from collections import defaultdict +from enum import Enum +import glob +from itertools import product +import logging +import os.path +import re +import sys +import warnings + +import six + +import scss.config as config +from scss.cssdefs import _spaces_re +from scss.cssdefs import _escape_chars_re +from scss.cssdefs import _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 BlockAtRuleHeader +from scss.rule import Namespace +from scss.rule import RuleAncestry +from scss.rule import SassRule +from scss.rule import UnparsedBlock +from scss.selector import Selector +from scss.source import SourceFile +from scss.types import Number +from scss.types import Boolean +from scss.types import String +from scss.types import List +from scss.types import Null +from scss.types import Undefined +from scss.types import Url +from scss.util import dequote +from scss.util import normalize_var # TODO put in... namespace maybe? + +try: + # Use C version if available + from scss._speedups import locate_blocks +except ImportError: + from scss._native import locate_blocks + + +# TODO should mention logging for the programmatic interface in the +# documentation +log = logging.getLogger(__name__) + + +# TODO produce this +# TODO is this a Library (can't define values), a Namespace (lot of +# semi-private functionality, no support for magic imports), or an Extension +# (doesn't exist yet)? +DEFAULT_NAMESPACE = Namespace(functions=ALL_BUILTINS_LIBRARY) + + +_default_rule_re = re.compile(r'(?i)\s+!default\Z') +_xcss_extends_re = re.compile(r'\s+extends\s+') + + +class OutputStyle(Enum): + nested = () + compact = () + compressed = () + expanded = () + + legacy = () # ??? + + +class SassDeprecationWarning(UserWarning): + # Note: DO NOT inherit from DeprecationWarning; it's turned off by default + # in 2.7 and later! + pass + + +def warn_deprecated(rule, message): + warnings.warn( + "{0} (at {1})".format(message, rule.file_and_line), + SassDeprecationWarning, + stacklevel=2, + ) + + +class Compiler(object): + """A Sass compiler. Stores settings and knows how to fire off a + compilation. Main entry point into compiling Sass. + """ + def __init__( + self, root='', search_path=('',), + namespace=DEFAULT_NAMESPACE, output_style='nested', + generate_source_map=False, + live_errors=False, warn_unused_imports=False, + super_selector='', + ): + """Configure a compiler. + + :param root: Directory to treat as the "project root". Search paths + and some custom extensions (e.g. Compass) are relative to this + directory. Defaults to the current directory. + :param search_path: List of paths to search for ``@import``s, relative + to ``root``. Absolute and parent paths are allowed here, but + ``@import`` will refuse to load files that aren't in one of the + directories here. Defaults to only the root. + :param namespace: Global namespace to inject into compiled Sass. See + the Namespace documentation for details. + :type namespace: :class:`Namespace` + """ + # normpath() will (textually) eliminate any use of .. + self.root = os.path.normpath(os.path.abspath(root)) + self.search_path = tuple( + os.path.normpath(os.path.join(self.root, path)) + for path in search_path + ) + + self.namespace = namespace + self.output_style = output_style + self.generate_source_map = generate_source_map + self.live_errors = live_errors + self.warn_unused_imports = warn_unused_imports + self.super_selector = super_selector + + self.calculator = Calculator() + + def make_compilation(self): + return Compilation(self) + + def call_and_catch_errors(self, f, *args, **kwargs): + """Call the given function with the given arguments. If it succeeds, + return its return value. If it raises a :class:`scss.errors.SassError` + and `live_errors` is turned on, return CSS containing a traceback and + error message. + """ + try: + return f(*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 compile(self): + compilation = self.make_compilation() + return self.call_and_catch_errors(compilation.run) + + +class Compilation(object): + """A single run of a compiler.""" + def __init__(self, compiler): + self.compiler = compiler + + # TODO this needs a write barrier, so assignment can't overwrite what's + # in the original namespace + self.root_namespace = compiler.namespace + + self.sources = [] + self.source_index = {} + self.dependency_map = defaultdict(frozenset) + self.rules = [] + + def add_source(self, source): + if source.path in self.source_index: + raise KeyError("Duplicate source %r" % source.path) + self.sources.append(source) + self.source_index[source.path] = source + + def run(self): + # 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 + # TODO i would love for the output of this function to be something + # useful for producing stats, so this stuff can live on the Scss + # class only + 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.compiler.generate_source_map: + if not source_file.is_real_file: + 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.path, + all_selectors, + 'selector' if all_selectors == 1 else 'selectors', + exceeded) + final_cont += fcont + + return final_cont + + 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) + + parts = _xcss_extends_re.split(raw_selectors, 1) # handle old xCSS extends + 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.sources: + rule = SassRule( + source_file=source_file, + + unparsed_contents=source_file.contents, + namespace=root_namespace, + ) + self.rules.append(rule) + children.append(rule) + + for rule in children: + self.manage_children(rule, scope) + + self._warn_unused_imports(self.rules[0]) + + def _warn_unused_imports(self, rule): + if not rule.legacy_compiler_options.get( + 'warn_unused', self.compiler.warn_unused_imports): + return + + for name, file_and_line in rule.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) + + #################################################################### + # At (@) blocks + if block.is_atrule: + code = block.directive + code = '_at_' + code.lower().replace(' ', '_')[1:] + try: + method = getattr(self, code) + except AttributeError: + if 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) + else: + method(calculator, 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) + + def _at_warn(self, calculator, rule, scope, block): + """ + Implements @warn + """ + value = calculator.calculate(block.argument) + log.warn(repr(value)) + + def _at_print(self, calculator, rule, scope, block): + """ + Implements @print + """ + value = calculator.calculate(block.argument) + sys.stderr.write("%s\n" % value) + + def _at_raw(self, calculator, rule, scope, block): + """ + Implements @raw + """ + value = calculator.calculate(block.argument) + sys.stderr.write("%s\n" % repr(value)) + + def _at_dump_context(self, calculator, rule, scope, block): + """ + Implements @dump_context + """ + sys.stderr.write("%s\n" % repr(rule.namespace._variables)) + + def _at_dump_functions(self, calculator, rule, scope, block): + """ + Implements @dump_functions + """ + sys.stderr.write("%s\n" % repr(rule.namespace._functions)) + + def _at_dump_mixins(self, calculator, rule, scope, block): + """ + Implements @dump_mixins + """ + sys.stderr.write("%s\n" % repr(rule.namespace._mixins)) + + def _at_dump_imports(self, calculator, rule, scope, block): + """ + Implements @dump_imports + """ + sys.stderr.write("%s\n" % repr(rule.namespace._imports)) + + def _at_dump_options(self, calculator, rule, scope, block): + """ + Implements @dump_options + """ + sys.stderr.write("%s\n" % repr(rule.options)) + + def _at_debug(self, calculator, rule, scope, block): + """ + Implements @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') + + def _at_pdb(self, calculator, rule, scope, block): + """ + Implements @pdb + """ + try: + import ipdb as pdb + except ImportError: + import pdb + pdb.set_trace() + + def _at_extend(self, calculator, rule, scope, block): + """ + Implements @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('') + + def _at_return(self, calculator, rule, scope, block): + """ + Implements @return + """ + # TODO should assert this only happens within a @function + ret = calculator.calculate(block.argument) + raise SassReturn(ret) + + # @print_timing(10) + def _at_option(self, calculator, rule, scope, block): + """ + Implements @option + """ + # TODO This only actually supports "style" (which only really makes + # sense as the first thing in a single input file) or "warn_unused" + # (which only makes sense at file level /at best/). Explore either + # replacing this with a better mechanism or dropping it entirely. + # Note also that all rules share the same underlying legacy option + # dict, so the rules aren't even lexically scoped like you might think, + # and @importing a file can change the compiler! That seems totally + # wrong. + for option in block.argument.split(','): + key, colon, value = option.partition(':') + key = key.strip().lower().replace('-', '_') + value = value.strip().lower() + + if value in ('1', 'true', 't', 'yes', 'y', 'on'): + value = True + elif value in ('0', 'false', 'f', 'no', 'n', 'off', 'undefined'): + value = False + elif not colon: + value = True + + if key == 'compress': + warn_deprecated( + rule, + "The 'compress' @option is deprecated. " + "Please use 'style' instead." + ) + key = 'style' + value = 'compressed' if value else 'legacy' + + if key in ('short_colors', 'reverse_colors'): + warn_deprecated( + rule, + "The '{0}' @option no longer has any effect." + .format(key), + ) + return + elif key == 'style': + try: + OutputStyle[value] + except KeyError: + raise SassError("No such output style: {0}".format(value)) + elif key in ('warn_unused', 'control_scoping'): + # TODO deprecate control_scoping? or add it to compiler? + if not isinstance(value, bool): + raise SassError("The '{0}' @option requires a bool, not {1!r}".format(key, value)) + else: + raise SassError("Unknown @option: {0}".format(key)) + + rule.legacy_compiler_options[key] = 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 _at_function(self, calculator, 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)) + + 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 + + # TODO a function or mixin is re-parsed every time it's called; there's + # no AST for anything but expressions :( + mixin = [rule.source_file, block.lineno, block.unparsed_contents, rule.namespace, argspec_node, rule.source_file] + 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, + legacy_compiler_options=rule.legacy_compiler_options, + 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) + _at_mixin = _at_function + + # @print_timing(10) + def _at_include(self, calculator, 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( + # These must be file and line in which the @include occurs + source_file=rule.source_file, + lineno=rule.lineno, + + # These must be file and line in which the @mixin was defined + from_source_file=source_file, + from_lineno=lineno, + + unparsed_contents=m_codestr, + namespace=callee_namespace, + + # rule + import_key=rule.import_key, + legacy_compiler_options=rule.legacy_compiler_options, + 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 _at_content(self, calculator, 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 _at_import(self, calculator, rule, scope, block): + """ + Implements @import + Load and import mixins and functions and rules + """ + # TODO it would be neat to opt into warning that you're using + # values/functions from a file you didn't explicitly import + # TODO base-level directives, like @mixin or @charset, aren't allowed + # to be @imported into a nested block + # TODO i'm not sure we disallow them nested in the first place + # TODO @import is disallowed within mixins, control directives + # TODO @import doesn't take a block -- that's probably an issue with a + # lot of our directives + + # TODO if there's any #{}-interpolation in the AST, this should become + # a CSS import (though in practice Ruby only even evaluates it in url() + # -- in a string it's literal!) + + sass_paths = calculator.evaluate_expression(block.argument) + css_imports = [] + + for sass_path in sass_paths: + # These are the rules for when an @import is interpreted as a CSS + # import: + if ( + # If it's a url() + isinstance(sass_path, Url) or + # If it's not a string (including `"foo" screen`, a List) + not isinstance(sass_path, String) or + # If the filename begins with an http protocol + sass_path.value.startswith(('http://', 'https://')) or + # If the filename ends with .css + sass_path.value.endswith('.css')): + css_imports.append(sass_path.render(compress=False)) + continue + + # Should be left with a plain String + name = sass_path.value + + source = None + try: + path = self._find_import(rule, name) + except IOError: + # Maybe do a special import instead + generated_code = self._at_magic_import( + calculator, rule, scope, block) + if generated_code is None: + raise + + source = SourceFile.from_string(generated_code) + else: + if path not in self.source_index: + self.add_source(SourceFile.from_filename(path)) + source = self.source_index[path] + + if rule.namespace.has_import(source): + # If already imported in this scope, skip + # TODO this might not be right -- consider if you @import a + # file at top level, then @import it inside a selector block! + continue + + _rule = SassRule( + source_file=source, + lineno=block.lineno, + unparsed_contents=source.contents, + + # rule + legacy_compiler_options=rule.legacy_compiler_options, + options=rule.options, + properties=rule.properties, + extends_selectors=rule.extends_selectors, + ancestry=rule.ancestry, + namespace=rule.namespace, + ) + rule.namespace.add_import(source, rule) + self.manage_children(_rule, scope) + + # Create a new @import rule for each import determined to be CSS + for import_ in css_imports: + # TODO this seems extremely janky (surely we should create an + # actual new Rule), but the CSS rendering doesn't understand how to + # print rules without blocks + rule.properties.append(('@import ' + import_, None)) + + def _find_import(self, rule, name, skip=None): + """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, basename = os.path.split(name) + + # Search relative to the importing file first + search_path = [os.path.dirname(rule.source_file.path)] + search_path.extend(self.compiler.search_path) + + for prefix, suffix in product(('_', ''), search_exts): + filename = prefix + basename + suffix + for directory in search_path: + path = os.path.normpath( + os.path.join(directory, dirname, filename)) + + if path == rule.source_file.path: + # Avoid self-import + # TODO is this what ruby does? + continue + + if not os.path.exists(path): + continue + + # Ensure that no one used .. to escape the search path + for valid_path in self.compiler.search_path: + rel = os.path.relpath(path, start=valid_path) + if not rel.startswith('../'): + break + else: + continue + + # All good! + return path + + raise IOError( + "Can't find a file to import for {0!r}\n" + "Search path:\n{1}".format( + name, + ''.join(" " + dir_ + "\n" for dir_ in search_path), + ) + ) + + # @print_timing(10) + def _at_magic_import(self, calculator, 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 = {} + + 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 _at_if(self, calculator, 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 + + condition = calculator.calculate(block.argument) + if condition: + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.legacy_compiler_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 + _at_else_if = _at_if + + # @print_timing(10) + def _at_else(self, calculator, 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 _at_for(self, calculator, rule, scope, block): + """ + Implements @for + """ + var, _, name = block.argument.partition(' from ') + frm, _, through = name.partition(' through ') + if through: + inclusive = True + else: + inclusive = False + frm, _, through = frm.partition(' to ') + 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.legacy_compiler_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 + + if inclusive: + through += 1 + for i in rev(range(frm, through)): + inner_rule.namespace.set_variable(var, Number(i)) + self.manage_children(inner_rule, scope) + + # @print_timing(10) + def _at_each(self, calculator, rule, scope, block): + """ + Implements @each + """ + varstring, _, valuestring = block.argument.partition(' in ') + values = calculator.calculate(valuestring) + if not values: + return + + varlist = [ + normalize_var(calculator.do_glob_math(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(varlist) > 1 + if not varlist[-1]: + varlist.pop() + + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.legacy_compiler_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): + if unpack: + 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) + else: + inner_rule.namespace.set_variable(varlist[0], v) + self.manage_children(inner_rule, scope) + + # @print_timing(10) + def _at_while(self, calculator, rule, scope, block): + """ + Implements @while + """ + first_condition = condition = calculator.calculate(block.argument) + while condition: + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.legacy_compiler_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 _at_variables(self, calculator, 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) + _at_vars = _at_variables + + # @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 = _default_rule_re.subn('', raw_value) # handle !default + 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 = rule.legacy_compiler_options.get( + 'style', self.compiler.output_style) + compress = style == 'compressed' + value = value.render(compress=compress) + + rule.properties.append((_prop, value)) + + # @print_timing(10) + def _nest_at_rules(self, rule, scope, block): + """ + Implements @-blocks + """ + # TODO handle @charset, probably? + # Interpolate the current block + # TODO this seems like it should be done in the block header. and more + # generally? + calculator = Calculator(rule.namespace) + if block.header.argument: + 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': + 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) + + rule.descendants += 1 + new_rule = SassRule( + source_file=rule.source_file, + import_key=rule.import_key, + lineno=block.lineno, + unparsed_contents=block.unparsed_contents, + + legacy_compiler_options=rule.legacy_compiler_options, + 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.source_file) + self.manage_children(new_rule, scope) + + self._warn_unused_imports(new_rule) + + # @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) + if c_parents: + warn_deprecated( + rule, + "The XCSS 'a extends b' syntax is deprecated. " + "Use 'a { @extend b; }' instead." + ) + + 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, + + legacy_compiler_options=rule.legacy_compiler_options, + 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.source_file) + self.manage_children(new_rule, scope) + + self._warn_unused_imports(new_rule) + + # @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) + for rule in self.rules: + 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: + # TODO should be fatal, unless !optional given + 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)) + + # Remove placeholder-only rules + self.rules = [rule for rule in self.rules if not rule.is_pure_placeholder] + + # @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 = rules[0].legacy_compiler_options.get( + 'style', self.compiler.output_style) + debug_info = self.compiler.generate_source_map + + if style == 'legacy': + sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', False, '', '\n', '\n', '\n', debug_info + elif style == 'compressed': + 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): + super_selector = self.compiler.super_selector + if super_selector: + super_selector += ' ' + + 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: + def _print_debug_info(filename, 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 + return result + + if rule.lineno and rule.source_file: + result += _print_debug_info(rule.source_file.path, rule.lineno) + + if rule.from_lineno and rule.from_source_file: + result += _print_debug_info(rule.from_source_file.path, rule.from_lineno) + + if header.is_selector: + header_string = header.render(sep=',' + sp, super_selector=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) diff --git a/scss/legacy.py b/scss/legacy.py new file mode 100644 index 0000000..095d2ce --- /dev/null +++ b/scss/legacy.py @@ -0,0 +1,178 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import division + +import six + +from scss.compiler import Compiler +import scss.config as config +from scss.functions import ALL_BUILTINS_LIBRARY +from scss.expression import Calculator +from scss.namespace import Namespace +from scss.scss_meta import ( + BUILD_INFO, PROJECT, VERSION, REVISION, URL, AUTHOR, AUTHOR_EMAIL, LICENSE, +) +from scss.source import SourceFile +from scss.types import String + + +_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('}'), +} + + +# TODO move this to a back-compat module so init is finally empty +# TODO using this should spew an actual deprecation warning +class Scss(object): + """Original programmatic interface to the compiler. + + This class is now DEPRECATED. See :mod:`scss.compiler` for the + replacement. + """ + def __init__( + self, scss_vars=None, scss_opts=None, scss_files=None, + super_selector='', live_errors=False, + library=ALL_BUILTINS_LIBRARY, func_registry=None, + search_paths=None): + + self.super_selector = 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 or {} + 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 + + def compile( + self, scss_string=None, scss_file=None, source_files=None, + super_selector=None, filename=None, is_sass=None, + line_numbers=True): + """Compile Sass to CSS. Returns a single CSS string. + + This method is DEPRECATED; see :mod:`scss.compiler` instead. + """ + # Derive our root namespace + self.scss_vars = _default_scss_vars.copy() + if self._scss_vars is not None: + self.scss_vars.update(self._scss_vars) + + 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 + 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" + search_paths.extend(self._search_paths) + else: + if config.LOAD_PATHS: + if isinstance(config.LOAD_PATHS, six.string_types): + # Back-compat: allow comma-delimited + search_paths.extend(config.LOAD_PATHS.split(',')) + else: + search_paths.extend(config.LOAD_PATHS) + + search_paths.extend(self._scss_opts.get('load_paths', [])) + + # Normalize a few old styles of options + output_style = self._scss_opts.get('style', config.STYLE) + if output_style is True: + output_style = 'compressed' + elif output_style is False: + output_style = 'legacy' + + # Build the compiler + compiler = Compiler( + namespace=root_namespace, + search_path=search_paths, + live_errors=self.live_errors, + generate_source_map=self._scss_opts.get('debug_info', False), + output_style=output_style, + warn_unused_imports=self._scss_opts.get('warn_unused', False), + super_selector=super_selector or self.super_selector, + ) + # Gonna add the source files manually + compilation = compiler.make_compilation() + + # Inject the files we know about + # TODO how does this work with the expectation of absoluteness + if source_files is not None: + for source in source_files: + compilation.add_source(source) + elif scss_string is not None: + source = SourceFile.from_string( + scss_string, + path=filename, + is_sass=is_sass, + ) + compilation.add_source(source) + elif scss_file is not None: + source = SourceFile.from_filename( + scss_file, + path=filename, + is_sass=is_sass, + ) + compilation.add_source(source) + + # Plus the ones from the constructor + if self._scss_files: + for name, contents in list(self._scss_files.items()): + source = SourceFile.from_string(contents, path=name) + compilation.add_source(source) + + return compiler.call_and_catch_errors(compilation.run) + + # Old, old alias + Compilation = compile + + 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[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('$') and k[1].isupper()) + ) diff --git a/scss/namespace.py b/scss/namespace.py new file mode 100644 index 0000000..f469c46 --- /dev/null +++ b/scss/namespace.py @@ -0,0 +1,217 @@ +"""Support for Sass's namespacing rules.""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging + +import six + +from scss.types import Undefined +from scss.types import Value + + +log = logging.getLogger(__name__) + + +def normalize_var(name): + if isinstance(name, six.string_types): + return name.replace('_', '-') + else: + log.warn("Variable name doesn't look like a string: %r", name) + return name + + +class Scope(object): + """Implements Sass variable scoping. + + Similar to `ChainMap`, except that assigning a new value will replace an + existing value, not mask it. + """ + def __init__(self, maps=()): + maps = list(maps) + assert all(isinstance(m, dict) or isinstance(m, type(self)) for m in maps) # Check all passed maps are compatible with the current Scope + self.maps = [dict()] + maps + + def __repr__(self): + return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join(repr(map) for map in self.maps), id(self)) + + def __getitem__(self, key): + for map in self.maps: + if key in map: + return map[key] + + raise KeyError(key) + + def __setitem__(self, key, value): + self.set(key, value) + + def __contains__(self, key): + try: + self[key] + except KeyError: + return False + else: + return True + + def keys(self): + # For mapping interface + keys = set() + for map in self.maps: + keys.update(map.keys()) + return list(keys) + + def set(self, key, value, force_local=False): + if not force_local: + for map in self.maps: + if key in map: + if isinstance(map[key], Undefined): + break + map[key] = value + return + + self.maps[0][key] = value + + def new_child(self): + return type(self)(self.maps) + + +class VariableScope(Scope): + pass + + +class FunctionScope(Scope): + def __repr__(self): + return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join('[%s]' % ', '.join('%s:%s' % (f, n) for f, n in sorted(map.keys())) for map in self.maps), id(self)) + + +class MixinScope(Scope): + def __repr__(self): + return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join('[%s]' % ', '.join('%s:%s' % (f, n) for f, n in sorted(map.keys())) for map in self.maps), id(self)) + + +class ImportScope(Scope): + pass + + +class Namespace(object): + """...""" + _mutable = True + + def __init__(self, variables=None, functions=None, mixins=None, mutable=True): + self._mutable = mutable + + if variables is None: + self._variables = VariableScope() + else: + # TODO parse into sass values once that's a thing, or require them + # all to be + self._variables = VariableScope([variables]) + + if functions is None: + self._functions = FunctionScope() + else: + self._functions = FunctionScope([functions._functions]) + + self._mixins = MixinScope() + + self._imports = ImportScope() + + def _assert_mutable(self): + if not self._mutable: + raise AttributeError("This Namespace instance is immutable") + + @classmethod + def derive_from(cls, *others): + self = cls() + if len(others) == 1: + self._variables = others[0]._variables.new_child() + self._functions = others[0]._functions.new_child() + self._mixins = others[0]._mixins.new_child() + self._imports = others[0]._imports.new_child() + else: + # Note that this will create a 2-dimensional scope where each of + # these scopes is checked first in order. TODO is this right? + self._variables = VariableScope(other._variables for other in others) + self._functions = FunctionScope(other._functions for other in others) + self._mixins = MixinScope(other._mixins for other in others) + self._imports = ImportScope(other._imports for other in others) + return self + + def derive(self): + """Return a new child namespace. All existing variables are still + readable and writeable, but any new variables will only exist within a + new scope. + """ + return type(self).derive_from(self) + + @property + def variables(self): + return dict((k, self._variables[k]) for k in self._variables.keys()) + + def variable(self, name, throw=False): + name = normalize_var(name) + return self._variables[name] + + def set_variable(self, name, value, local_only=False): + self._assert_mutable() + name = normalize_var(name) + if not isinstance(value, Value): + raise TypeError("Expected a Sass type, while setting %s got %r" % (name, value,)) + self._variables.set(name, value, force_local=local_only) + + def has_import(self, source): + return source.path in self._imports + + def add_import(self, source, parent_rule): + self._assert_mutable() + self._imports[source.path] = [ + 0, + parent_rule.source_file.path, + parent_rule.file_and_line, + ] + + def use_import(self, import_key): + self._assert_mutable() + if import_key and import_key in self._imports: + imports = self._imports[import_key] + imports[0] += 1 + self.use_import(imports[1]) + + def unused_imports(self): + unused = [] + for import_key in self._imports.keys(): + imports = self._imports[import_key] + if not imports[0]: + unused.append((import_key[0], imports[2])) + return unused + + def _get_callable(self, chainmap, name, arity): + name = normalize_var(name) + if arity is not None: + # With explicit arity, try the particular arity before falling back + # to the general case (None) + try: + return chainmap[name, arity] + except KeyError: + pass + + return chainmap[name, None] + + def _set_callable(self, chainmap, name, arity, cb): + name = normalize_var(name) + chainmap[name, arity] = cb + + def mixin(self, name, arity): + return self._get_callable(self._mixins, name, arity) + + def set_mixin(self, name, arity, cb): + self._assert_mutable() + self._set_callable(self._mixins, name, arity, cb) + + def function(self, name, arity): + return self._get_callable(self._functions, name, arity) + + def set_function(self, name, arity, cb): + self._assert_mutable() + self._set_callable(self._functions, name, arity, cb) diff --git a/scss/rule.py b/scss/rule.py index 1750112..e39e121 100644 --- a/scss/rule.py +++ b/scss/rule.py @@ -2,9 +2,8 @@ from __future__ import absolute_import from __future__ import print_function import logging -import six -from scss.types import Value, Undefined +from scss.namespace import Namespace log = logging.getLogger(__name__) @@ -13,14 +12,6 @@ SORTED_SELECTORS = False sort = sorted if SORTED_SELECTORS else lambda it: it -def normalize_var(name): - if isinstance(name, six.string_types): - return name.replace('_', '-') - else: - log.warn("Variable name doesn't look like a string: %r", name) - return name - - def extend_unique(seq, more): """Return a new sequence containing the items in `seq` plus any items in `more` that aren't already in `seq`, preserving the order of both. @@ -35,207 +26,15 @@ def extend_unique(seq, more): return seq + type(seq)(new) -class Scope(object): - """Implements Sass variable scoping. - - Similar to `ChainMap`, except that assigning a new value will replace an - existing value, not mask it. - """ - def __init__(self, maps=()): - maps = list(maps) - assert all(isinstance(m, dict) or isinstance(m, type(self)) for m in maps) # Check all passed maps are compatible with the current Scope - self.maps = [dict()] + maps - - def __repr__(self): - return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join(repr(map) for map in self.maps), id(self)) - - def __getitem__(self, key): - for map in self.maps: - if key in map: - return map[key] - - raise KeyError(key) - - def __setitem__(self, key, value): - self.set(key, value) - - def __contains__(self, key): - try: - self[key] - except KeyError: - return False - else: - return True - - def keys(self): - # For mapping interface - keys = set() - for map in self.maps: - keys.update(map.keys()) - return list(keys) - - def set(self, key, value, force_local=False): - if not force_local: - for map in self.maps: - if key in map: - if isinstance(map[key], Undefined): - break - map[key] = value - return - - self.maps[0][key] = value - - def new_child(self): - return type(self)(self.maps) - - -class VariableScope(Scope): - pass - - -class FunctionScope(Scope): - def __repr__(self): - return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join('[%s]' % ', '.join('%s:%s' % (f, n) for f, n in sorted(map.keys())) for map in self.maps), id(self)) - - -class MixinScope(Scope): - def __repr__(self): - return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join('[%s]' % ', '.join('%s:%s' % (f, n) for f, n in sorted(map.keys())) for map in self.maps), id(self)) - - -class ImportScope(Scope): - pass - - -class Namespace(object): - """...""" - _mutable = True - - def __init__(self, variables=None, functions=None, mixins=None, mutable=True): - self._mutable = mutable - - if variables is None: - self._variables = VariableScope() - else: - # TODO parse into sass values once that's a thing, or require them - # all to be - self._variables = VariableScope([variables]) - - if functions is None: - self._functions = FunctionScope() - else: - self._functions = FunctionScope([functions._functions]) - - self._mixins = MixinScope() - - self._imports = ImportScope() - - def _assert_mutable(self): - if not self._mutable: - raise AttributeError("This Namespace instance is immutable") - - @classmethod - def derive_from(cls, *others): - self = cls() - if len(others) == 1: - self._variables = others[0]._variables.new_child() - self._functions = others[0]._functions.new_child() - self._mixins = others[0]._mixins.new_child() - self._imports = others[0]._imports.new_child() - else: - # Note that this will create a 2-dimensional scope where each of - # these scopes is checked first in order. TODO is this right? - self._variables = VariableScope(other._variables for other in others) - self._functions = FunctionScope(other._functions for other in others) - self._mixins = MixinScope(other._mixins for other in others) - self._imports = ImportScope(other._imports for other in others) - return self - - def derive(self): - """Return a new child namespace. All existing variables are still - readable and writeable, but any new variables will only exist within a - new scope. - """ - return type(self).derive_from(self) - - @property - def variables(self): - return dict((k, self._variables[k]) for k in self._variables.keys()) - - def variable(self, name, throw=False): - name = normalize_var(name) - return self._variables[name] - - def set_variable(self, name, value, local_only=False): - self._assert_mutable() - name = normalize_var(name) - if not isinstance(value, Value): - raise TypeError("Expected a Sass type, while setting %s got %r" % (name, value,)) - self._variables.set(name, value, force_local=local_only) - - def has_import(self, import_key): - return import_key in self._imports - - def add_import(self, import_key, parent_import_key, file_and_line): - self._assert_mutable() - if import_key: - imports = [0, parent_import_key, file_and_line] - self._imports[import_key] = imports - - def use_import(self, import_key): - self._assert_mutable() - if import_key and import_key in self._imports: - imports = self._imports[import_key] - imports[0] += 1 - self.use_import(imports[1]) - - def unused_imports(self): - unused = [] - for import_key in self._imports.keys(): - imports = self._imports[import_key] - if not imports[0]: - unused.append((import_key[0], imports[2])) - return unused - - def _get_callable(self, chainmap, name, arity): - name = normalize_var(name) - if arity is not None: - # With explicit arity, try the particular arity before falling back - # to the general case (None) - try: - return chainmap[name, arity] - except KeyError: - pass - - return chainmap[name, None] - - def _set_callable(self, chainmap, name, arity, cb): - name = normalize_var(name) - chainmap[name, arity] = cb - - def mixin(self, name, arity): - return self._get_callable(self._mixins, name, arity) - - def set_mixin(self, name, arity, cb): - self._assert_mutable() - self._set_callable(self._mixins, name, arity, cb) - - def function(self, name, arity): - return self._get_callable(self._functions, name, arity) - - def set_function(self, name, arity, cb): - self._assert_mutable() - self._set_callable(self._functions, name, arity, cb) - - class SassRule(object): """At its heart, a CSS rule: combination of a selector and zero or more properties. But this is Sass, so it also tracks some Sass-flavored metadata, like `@extend` rules and `@media` nesting. """ - def __init__(self, source_file, import_key=None, unparsed_contents=None, - options=None, properties=None, + def __init__( + self, source_file, import_key=None, unparsed_contents=None, + options=None, legacy_compiler_options=None, properties=None, namespace=None, lineno=0, extends_selectors=frozenset(), ancestry=None, @@ -250,7 +49,8 @@ class SassRule(object): self.lineno = lineno self.unparsed_contents = unparsed_contents - self.options = options + self.legacy_compiler_options = legacy_compiler_options or {} + self.options = options or {} self.extends_selectors = extends_selectors if namespace is None: @@ -292,9 +92,9 @@ class SassRule(object): """Return the filename and line number where this rule originally appears, in the form "foo.scss:3". Used for error messages. """ - ret = "%s:%d" % (self.source_file.filename, self.lineno) + ret = "%s:%d" % (self.source_file.path, self.lineno) if self.from_source_file: - ret += " (%s:%d)" % (self.from_source_file.filename, self.from_lineno) + ret += " (%s:%d)" % (self.from_source_file.path, self.from_lineno) return ret @property @@ -337,6 +137,7 @@ class SassRule(object): unparsed_contents=self.unparsed_contents, + legacy_compiler_options=self.legacy_compiler_options, options=self.options, #properties=list(self.properties), properties=self.properties, diff --git a/scss/source.py b/scss/source.py new file mode 100644 index 0000000..8f68819 --- /dev/null +++ b/scss/source.py @@ -0,0 +1,278 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import division + +import hashlib +import logging +import os +import re + +import six + +from scss.cssdefs import ( + _ml_comment_re, _sl_comment_re, + _expand_rules_space_re, _collapse_properties_space_re, + _strings_re, +) +from scss.cssdefs import determine_encoding + + +log = logging.getLogger(__name__) + + +_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))) + + +class SourceFile(object): + """A single input file to be fed to the compiler. Detects the encoding + (according to CSS spec rules) and performs some light pre-processing. + """ + + path = None + """For "real" files, an absolute path to the original source file. For ad + hoc strings, some other kind of identifier. This is used as a hash key and + a test of equality, so it MUST be unique! + """ + + def __init__( + self, path, contents, encoding=None, + is_real_file=True, is_sass=None): + """Not normally used. See the three alternative constructors: + :func:`SourceFile.from_file`, :func:`SourceFile.from_filename`, and + :func:`SourceFile.from_string`. + """ + if not isinstance(contents, six.text_type): + raise TypeError( + "Expected bytes for 'contents', got {0}" + .format(type(contents))) + + if is_real_file and not os.path.isabs(path): + raise ValueError( + "Expected an absolute path for 'path', got {0!r}" + .format(path)) + + self.path = path + self.encoding = encoding + if is_sass is None: + # TODO autodetect from the contents if the extension is bogus or + # missing? + self.is_sass = os.path.splitext(path)[1] == '.sass' + else: + self.is_sass = is_sass + self.contents = self.prepare_source(contents) + self.is_real_file = is_real_file + + def __repr__(self): + return "<{0} {1!r}>".format(type(self).__name__, self.path) + + def __hash__(self): + return hash(self.path) + + def __eq__(self, other): + if self is other: + return True + + if not isinstance(other, SourceFile): + return NotImplemented + + return self.path == other.path + + def __ne__(self, other): + return not self == other + + @classmethod + def from_filename(cls, fn, path=None, **kwargs): + """Read Sass source from a file on disk.""" + # Open in binary mode so we can reliably detect the encoding + with open(fn, 'rb') as f: + return cls.from_file(f, path=path or fn, **kwargs) + + @classmethod + def from_file(cls, f, path=None, **kwargs): + """Read Sass source from a file or file-like object.""" + contents = f.read() + encoding = determine_encoding(contents) + if isinstance(contents, six.binary_type): + contents = contents.decode(encoding) + + is_real_file = False + if path is None: + path = getattr(f, 'name', repr(f)) + elif os.path.exists(path): + path = os.path.normpath(os.path.abspath(path)) + is_real_file = True + + return cls( + path, contents, encoding=encoding, is_real_file=is_real_file, + **kwargs) + + @classmethod + def from_string(cls, string, path=None, encoding=None, is_sass=None): + """Read Sass source from the contents of a string.""" + if isinstance(string, six.text_type): + # Already decoded; we don't know what encoding to use for output, + # though, so still check for a @charset. + # TODO what if the given encoding conflicts with the one in the + # file? do we care? + if encoding is None: + encoding = determine_encoding(string) + + byte_contents = string.encode(encoding) + text_contents = string + elif isinstance(string, six.binary_type): + encoding = determine_encoding(string) + byte_contents = string + text_contents = string.decode(encoding) + else: + raise TypeError("Expected text or bytes, got {0!r}".format(string)) + + is_real_file = False + if path is None: + m = hashlib.sha256() + m.update(byte_contents) + path = 'string:' + m.hexdigest().decode('ascii') + elif os.path.exists(path): + path = os.path.normpath(os.path.abspath(path)) + is_real_file = True + + return cls( + path, text_contents, encoding=encoding, is_real_file=is_real_file, + is_sass=is_sass, + ) + + 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'] + output = output.strip() + + state['prev_line'] = line + state['prev_line_no'] = line_no + + if 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'] + output = output.strip() + + state['prev_indent'] = indent + state['prev_line'] = line + state['prev_line_no'] = line_no + + if 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.is_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) + # parse the last line stored in prev_line buffer + codestr += parse_line(None, None, state) + + # 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 diff --git a/scss/tests/files/bugs/extend-selector-order.css b/scss/tests/files/bugs/extend-selector-order.css new file mode 100644 index 0000000..619fc57 --- /dev/null +++ b/scss/tests/files/bugs/extend-selector-order.css @@ -0,0 +1,7 @@ +.nav-pills > li { + float: left; +} + +.nav-justified > li, .nav-tabs.nav-justified > li { + float: none; +} diff --git a/scss/tests/files/bugs/extend-selector-order.scss b/scss/tests/files/bugs/extend-selector-order.scss new file mode 100644 index 0000000..9a54cb3 --- /dev/null +++ b/scss/tests/files/bugs/extend-selector-order.scss @@ -0,0 +1,17 @@ +.nav-tabs { + &.nav-justified { + @extend .nav-justified; + } +} + +.nav-pills { + > li { + float: left; + } +} + +.nav-justified { + > li { + float: none; + } +} diff --git a/scss/tests/files/bugs/for-to-vs-through.css b/scss/tests/files/bugs/for-to-vs-through.css new file mode 100644 index 0000000..3d50671 --- /dev/null +++ b/scss/tests/files/bugs/for-to-vs-through.css @@ -0,0 +1,14 @@ +a { + prop: 1; + prop: 2; + prop: 3; + prop: 4; +} + +b { + prop: 1; + prop: 2; + prop: 3; + prop: 4; + prop: 5; +} diff --git a/scss/tests/files/bugs/for-to-vs-through.scss b/scss/tests/files/bugs/for-to-vs-through.scss new file mode 100644 index 0000000..c5af514 --- /dev/null +++ b/scss/tests/files/bugs/for-to-vs-through.scss @@ -0,0 +1,12 @@ +// `to` excludes the upper bound, but `through` does not +a { + @for $var from 1 to 5 { + prop: $var; + } +} + +b { + @for $var from 1 through 5 { + prop: $var; + } +} diff --git a/scss/tests/files/original-doctests/007-extends-3.css b/scss/tests/files/original-doctests/007-extends-3.css index 1b17778..1bb67f4 100644 --- a/scss/tests/files/original-doctests/007-extends-3.css +++ b/scss/tests/files/original-doctests/007-extends-3.css @@ -1,3 +1,7 @@ +.specialClass { + padding: 10px; + font-size: 14px; +} .basicClass, .specialClass { padding: 20px; background-color: #FF0000; @@ -5,7 +9,3 @@ .basicClass a, .specialClass a, .specialLink { text-decoration: none; } -.specialClass { - padding: 10px; - font-size: 14px; -} diff --git a/scss/tests/test_misc.py b/scss/tests/test_misc.py index beb231a..0c6c140 100644 --- a/scss/tests/test_misc.py +++ b/scss/tests/test_misc.py @@ -84,14 +84,14 @@ def test_extend_across_files(): ''' actual = compiler.compile() expected = """\ -.basicClass, .specialClass { - padding: 20px; - background-color: #FF0000; -} .specialClass { padding: 10px; font-size: 14px; } +.basicClass, .specialClass { + padding: 20px; + background-color: #FF0000; +} """ assert expected == actual diff --git a/scss/tool.py b/scss/tool.py index d3a567c..94f388b 100644 --- a/scss/tool.py +++ b/scss/tool.py @@ -2,29 +2,34 @@ from __future__ import absolute_import from __future__ import print_function +from collections import deque from contextlib import contextmanager import logging import os import re import sys -from collections import deque from scss import config -from scss.util import profiling -from scss import Scss, SourceFile, log -from scss import _prop_split_re +from scss.compiler import _prop_split_re +from scss.compiler import Compiler +from scss.errors import SassEvaluationError +from scss.expression import Calculator +from scss.legacy import Scss +from scss.legacy import _default_scss_vars +from scss.namespace import Namespace from scss.rule import SassRule from scss.rule import UnparsedBlock -from scss.expression import Calculator from scss.scss_meta import BUILD_INFO -from scss.errors import SassEvaluationError +from scss.source import SourceFile +from scss.util import profiling try: raw_input except NameError: raw_input = input -log.setLevel(logging.INFO) +log = logging.getLogger(__name__) +logging.getLogger('scss').setLevel(logging.INFO) def main(): @@ -173,14 +178,15 @@ def do_build(options, args): 'style': options.style, 'debug_info': options.debug_info, }) - if args: - source_files = [ - SourceFile.from_file(sys.stdin, "<stdin>", is_sass=options.is_sass) if path == '-' else SourceFile.from_filename(path, is_sass=options.is_sass) - for path in args - ] - else: - source_files = [ - SourceFile.from_file(sys.stdin, "<stdin>", is_sass=options.is_sass)] + if not args: + args = ['-'] + source_files = [] + for path in args: + if path == '-': + source = SourceFile.from_file(sys.stdin, "<stdin>", is_sass=options.is_sass) + else: + source = SourceFile.from_filename(path, is_sass=options.is_sass) + source_files.append(source) encodings = set(source.encoding for source in source_files) if len(encodings) > 1: @@ -315,13 +321,18 @@ def run_repl(is_sass=False): class SassRepl(object): def __init__(self, is_sass=False): - self.css = Scss() - self.namespace = self.css.root_namespace - self.options = self.css.scss_opts - self.source_file = SourceFile.from_string('', '<shell>', line_numbers=False, is_sass=is_sass) + # TODO it would be lovely to get these out of here, somehow + self.namespace = Namespace(variables=_default_scss_vars) + + self.compiler = Compiler(namespace=self.namespace) + self.compilation = self.compiler.make_compilation() + self.legacy_compiler_options = {} + self.source_file = SourceFile.from_string('', '<shell>', is_sass=is_sass) self.calculator = Calculator(self.namespace) def __call__(self, s): + # TODO this is kind of invasive; surely it's possible to do this + # without calling only private methods from pprint import pformat if s in ('exit', 'quit'): @@ -335,25 +346,25 @@ class SassRepl(object): scope = None properties = [] children = deque() - rule = SassRule(self.source_file, namespace=self.namespace, options=self.options, properties=properties) + rule = SassRule(self.source_file, namespace=self.namespace, legacy_compiler_options=self.legacy_compiler_options, properties=properties) block = UnparsedBlock(rule, 1, s, None) code, name = (s.split(None, 1) + [''])[:2] if code == '@option': - self.css._at_options(self.calculator, rule, scope, block) + self.compilation._at_options(self.calculator, rule, scope, block) continue elif code == '@import': - self.css._at_import(self.calculator, rule, scope, block) + self.compilation._at_import(self.calculator, rule, scope, block) continue elif code == '@include': final_cont = '' - self.css._at_include(self.calculator, rule, scope, block) - code = self.css._print_properties(properties).rstrip('\n') + self.compilation._at_include(self.calculator, rule, scope, block) + code = self.compilation._print_properties(properties).rstrip('\n') if code: final_cont += code if children: - self.css.children.extendleft(children) - self.css.parse_children() - code = self.css._create_css(self.css.rules).rstrip('\n') + self.compilation.children.extendleft(children) + self.compilation.parse_children() + code = self.compilation._create_css(self.compilation.rules).rstrip('\n') if code: final_cont += code yield final_cont @@ -367,7 +378,7 @@ class SassRepl(object): code = code and code.strip() ns = self.namespace if not name: - yield pformat(sorted(['vars', 'options', 'mixins', 'functions'])) + yield pformat(list(sorted(['vars', 'options', 'mixins', 'functions']))) elif name in ('v', 'var', 'variable'): variables = dict(ns._variables) if code == '*': @@ -379,13 +390,13 @@ class SassRepl(object): yield pformat(variables) elif name in ('o', 'opt', 'option'): - opts = self.options + opts = self.legacy_compiler_options if code == '*': pass elif code: opts = dict((k, v) for k, v in opts.items() if code in k) else: - opts = dict((k, v) for k, v in opts.items() if not k.startswith('@')) + opts = dict((k, v) for k, v in opts.items()) yield pformat(opts) elif name in ('m', 'mix', 'mixin', 'f', 'func', 'funct', 'function'): |