summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEevee (Alex Munroe) <eevee.git@veekun.com>2014-08-24 14:17:00 -0700
committerEevee (Alex Munroe) <eevee.git@veekun.com>2014-08-24 14:17:00 -0700
commit7cd1e2909d805f8e878fee2b6596d40dbd780f6f (patch)
tree3f3af04f1f3a5720a6d097eff8343fb27401a589
parent55a07b764d305fb7b168c07d31d92a49faf92f31 (diff)
parentfa72d19af800a9819464d4b56824f49256ff7af9 (diff)
downloadpyscss-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__.py1657
-rw-r--r--scss/compiler.py1531
-rw-r--r--scss/legacy.py178
-rw-r--r--scss/namespace.py217
-rw-r--r--scss/rule.py217
-rw-r--r--scss/source.py278
-rw-r--r--scss/tests/files/bugs/extend-selector-order.css7
-rw-r--r--scss/tests/files/bugs/extend-selector-order.scss17
-rw-r--r--scss/tests/files/bugs/for-to-vs-through.css14
-rw-r--r--scss/tests/files/bugs/for-to-vs-through.scss12
-rw-r--r--scss/tests/files/original-doctests/007-extends-3.css8
-rw-r--r--scss/tests/test_misc.py8
-rw-r--r--scss/tool.py71
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'):