summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scss/compiler.py170
-rw-r--r--scss/legacy.py9
-rw-r--r--scss/namespace.py14
-rw-r--r--scss/rule.py4
-rw-r--r--scss/source.py170
-rw-r--r--scss/tool.py19
6 files changed, 250 insertions, 136 deletions
diff --git a/scss/compiler.py b/scss/compiler.py
index 1b4f05d..040e8f0 100644
--- a/scss/compiler.py
+++ b/scss/compiler.py
@@ -23,7 +23,9 @@ 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
@@ -34,6 +36,7 @@ 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?
@@ -93,8 +96,13 @@ class Compiler(object):
the Namespace documentation for details.
:type namespace: :class:`Namespace`
"""
- self.root = root
- self.search_path = search_path
+ # 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
@@ -123,13 +131,14 @@ class Compilation(object):
self.sources = []
self.source_index = {}
+ self.dependency_map = defaultdict(frozenset)
self.rules = []
def add_source(self, source):
- if source.filename in self.source_index:
- raise KeyError("Duplicate filename %r" % source.filename)
+ if source.path in self.source_index:
+ raise KeyError("Duplicate source %r" % source.path)
self.sources.append(source)
- self.source_index[source.filename] = source
+ self.source_index[source.path] = source
def run(self):
# this will compile and manage rule: child objects inside of a node
@@ -157,7 +166,7 @@ class Compilation(object):
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 source_file.is_string:
+ 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',
@@ -168,7 +177,7 @@ class Compilation(object):
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,
+ source_file.path,
all_selectors,
'selector' if all_selectors == 1 else 'selectors',
exceeded)
@@ -523,7 +532,7 @@ class Compilation(object):
# 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.import_key]
+ 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):
@@ -673,44 +682,64 @@ class Compilation(object):
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)
+ # 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
- source = 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 = SourceFile.from_string(i_codestr)
+ # Should be left with a plain String
+ name = sass_path.value
- elif full_filename in self.source_index:
- source = self.source_index[full_filename]
+ 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:
- source = SourceFile.from_filename(full_filename)
- self.add_source(source)
+ if path not in self.source_index:
+ self.add_source(SourceFile.from_filename(path))
+ source = self.source_index[path]
- if source 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.parent_dir)
- if rule.namespace.has_import(import_key):
+ 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,
- import_key=import_key,
lineno=block.lineno,
unparsed_contents=source.contents,
@@ -722,9 +751,16 @@ class Compilation(object):
ancestry=rule.ancestry,
namespace=rule.namespace,
)
- rule.namespace.add_import(import_key, rule.import_key, rule.file_and_line)
+ 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.
@@ -736,26 +772,44 @@ class Compilation(object):
else:
search_exts = ['.scss', '.sass']
- dirname, name = os.path.split(name)
+ dirname, basename = os.path.split(name)
- seen_paths = []
+ # Search relative to the importing file first
+ search_path = [os.path.dirname(rule.source_file.path)]
+ search_path.extend(self.compiler.search_path)
- for path in self.compiler.search_path:
- for basepath in [rule.source_file.parent_dir, '.']:
- full_path = os.path.realpath(os.path.join(basepath, path, dirname))
+ 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 full_path in seen_paths:
+ if path == rule.source_file.path:
+ # Avoid self-import
+ # TODO is this what ruby does?
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
+ 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
- return None, seen_paths
+ 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):
@@ -1068,7 +1122,6 @@ class Compilation(object):
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))
@@ -1080,7 +1133,6 @@ class Compilation(object):
else:
new_ancestry.append(block.header)
- from scss.rule import RuleAncestry
rule.descendants += 1
new_rule = SassRule(
source_file=rule.source_file,
@@ -1098,7 +1150,7 @@ class Compilation(object):
nested=rule.nested + 1,
)
self.rules.append(new_rule)
- rule.namespace.use_import(rule.import_key)
+ rule.namespace.use_import(rule.source_file)
self.manage_children(new_rule, scope)
self._warn_unused_imports(new_rule)
@@ -1133,7 +1185,7 @@ class Compilation(object):
nested=rule.nested + 1,
)
self.rules.append(new_rule)
- rule.namespace.use_import(rule.import_key)
+ rule.namespace.use_import(rule.source_file)
self.manage_children(new_rule, scope)
self._warn_unused_imports(new_rule)
@@ -1383,11 +1435,11 @@ class Compilation(object):
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.lineno and rule.source_file and rule.source_file.is_real_file:
+ result += _print_debug_info(rule.source_file.path, 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 rule.from_lineno and rule.from_source_file and rule.from_source_file.is_real_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)
diff --git a/scss/legacy.py b/scss/legacy.py
index 7281931..b6ed8a5 100644
--- a/scss/legacy.py
+++ b/scss/legacy.py
@@ -136,30 +136,29 @@ class Scss(object):
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,
- filename=filename,
+ path=filename,
is_sass=is_sass,
- line_numbers=line_numbers,
)
compilation.add_source(source)
elif scss_file is not None:
source = SourceFile.from_filename(
scss_file,
- filename=filename,
+ path=filename,
is_sass=is_sass,
- line_numbers=line_numbers,
)
compilation.add_source(source)
# Plus the ones from the constructor
if self._scss_files is not None:
for name, contents in list(self._scss_files.items()):
- source = SourceFile.from_string(contents, filename=name)
+ source = SourceFile.from_string(contents, path=name)
compilation.add_source(source)
return compilation.run()
diff --git a/scss/namespace.py b/scss/namespace.py
index 0dfb88b..f469c46 100644
--- a/scss/namespace.py
+++ b/scss/namespace.py
@@ -160,14 +160,16 @@ class Namespace(object):
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 has_import(self, source):
+ return source.path in self._imports
- def add_import(self, import_key, parent_import_key, file_and_line):
+ def add_import(self, source, parent_rule):
self._assert_mutable()
- if import_key:
- imports = [0, parent_import_key, file_and_line]
- self._imports[import_key] = imports
+ self._imports[source.path] = [
+ 0,
+ parent_rule.source_file.path,
+ parent_rule.file_and_line,
+ ]
def use_import(self, import_key):
self._assert_mutable()
diff --git a/scss/rule.py b/scss/rule.py
index 19e22be..e39e121 100644
--- a/scss/rule.py
+++ b/scss/rule.py
@@ -92,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
diff --git a/scss/source.py b/scss/source.py
index 0991998..8f68819 100644
--- a/scss/source.py
+++ b/scss/source.py
@@ -2,6 +2,9 @@ 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
@@ -15,6 +18,9 @@ from scss.cssdefs import (
from scss.cssdefs import determine_encoding
+log = logging.getLogger(__name__)
+
+
_safe_strings = {
'^doubleslash^': '//',
'^bigcopen^': '/*',
@@ -26,78 +32,126 @@ _safe_strings = {
}
_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)))
+_reverse_safe_strings_re = re.compile('|'.join(
+ map(re.escape, _reverse_safe_strings)))
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
-
+ """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
- self.sass = filename.endswith('.sass') if is_sass is None else is_sass
- self.line_numbers = line_numbers
- self.line_strip = line_strip
+ 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_string = is_string
+ self.is_real_file = is_real_file
def __repr__(self):
- return "<SourceFile '%s' at 0x%x>" % (
- self.filename,
- id(self),
- )
+ return "<{0} {1!r}>".format(type(self).__name__, self.path)
+
+ def __hash__(self):
+ return hash(self.path)
- @property
- def full_filename(self):
- if self.is_string:
- return self.filename
- return os.path.join(self.parent_dir, self.filename)
+ 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, filename=None, parent_dir=None, **kwargs):
+ 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, filename=filename, parent_dir=parent_dir, **kwargs)
+ return cls.from_file(f, path=path or fn, **kwargs)
@classmethod
- def from_file(cls, f, filename=None, parent_dir=None, **kwargs):
+ 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)
- if filename is None:
- filename = getattr(f, 'name', None)
+ 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(filename, contents, encoding=encoding, parent_dir=parent_dir, **kwargs)
+ return cls(
+ path, contents, encoding=encoding, is_real_file=is_real_file,
+ **kwargs)
@classmethod
- def from_string(cls, string, filename=None, parent_dir=None, is_sass=None, line_numbers=True):
+ 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.
- encoding = determine_encoding(string)
+ # 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)
- string = string.decode(encoding)
+ byte_contents = string
+ text_contents = 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)
+ 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 = ''
@@ -114,8 +168,7 @@ class SourceFile(object):
state['line_buffer'] = ''
output = state['prev_line']
- if self.line_strip:
- output = output.strip()
+ output = output.strip()
state['prev_line'] = line
state['prev_line_no'] = line_no
@@ -142,7 +195,8 @@ class SourceFile(object):
indent = len(line) - len(line.lstrip())
- # make sure we support multi-space indent as long as indent is consistent
+ # make sure we support multi-space indent as long as indent is
+ # consistent
if indent and not state['indent_marker']:
state['indent_marker'] = indent
@@ -154,7 +208,8 @@ class SourceFile(object):
if state['prev_line']:
state['prev_line'] += ';'
elif indent > state['prev_indent']:
- # new indentation is greater than previous, we just entered a new block
+ # new indentation is greater than previous, we just entered a new
+ # block
state['prev_line'] += ' {'
state['nested_blocks'] += 1
else:
@@ -166,8 +221,7 @@ class SourceFile(object):
state['nested_blocks'] -= block_diff
output = state['prev_line']
- if self.line_strip:
- output = output.strip()
+ output = output.strip()
state['prev_indent'] = indent
state['prev_line'] = line
@@ -179,7 +233,8 @@ class SourceFile(object):
return ret
def prepare_source(self, codestr, sass=False):
- # Decorate lines with their line numbers and a delimiting NUL and remove empty lines
+ # Decorate lines with their line numbers and a delimiting NUL and
+ # remove empty lines
state = {
'line_buffer': '',
'prev_line': '',
@@ -188,7 +243,7 @@ class SourceFile(object):
'nested_blocks': 0,
'indent_marker': 0,
}
- if self.sass:
+ if self.is_sass:
parse_line = self.parse_sass_line
else:
parse_line = self.parse_scss_line
@@ -196,10 +251,14 @@ class SourceFile(object):
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
+ # 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)
+ 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)
@@ -207,7 +266,8 @@ class SourceFile(object):
# 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)
+ 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)
diff --git a/scss/tool.py b/scss/tool.py
index 4718c8d..cc5f191 100644
--- a/scss/tool.py
+++ b/scss/tool.py
@@ -176,14 +176,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:
@@ -322,7 +323,7 @@ class SassRepl(object):
self.namespace = self.compiler.namespace
self.compilation = self.compiler.make_compilation()
self.legacy_compiler_options = {}
- self.source_file = SourceFile.from_string('', '<shell>', line_numbers=False, is_sass=is_sass)
+ self.source_file = SourceFile.from_string('', '<shell>', is_sass=is_sass)
self.calculator = Calculator(self.namespace)
def __call__(self, s):