diff options
-rw-r--r-- | scss/compiler.py | 170 | ||||
-rw-r--r-- | scss/legacy.py | 9 | ||||
-rw-r--r-- | scss/namespace.py | 14 | ||||
-rw-r--r-- | scss/rule.py | 4 | ||||
-rw-r--r-- | scss/source.py | 170 | ||||
-rw-r--r-- | scss/tool.py | 19 |
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): |