Skip to content

Commit bceff1b

Browse files
Move Ruby SASS compiler to ruby_sass module
1 parent 8c811a0 commit bceff1b

File tree

3 files changed

+336
-335
lines changed

3 files changed

+336
-335
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import os
2+
import posixpath
3+
import re
4+
from typing import List, Match, Optional
5+
6+
from .. import exceptions, url_converter, utils
7+
from ..types import StrCollection
8+
from . import base
9+
10+
__all__ = (
11+
"SCSS",
12+
"SASS",
13+
)
14+
15+
16+
class SCSS(base.BaseCompiler):
17+
18+
name = "scss"
19+
supports_dependencies = True
20+
input_extension = "scss"
21+
output_extension = "css"
22+
import_extensions = ("scss", "sass")
23+
24+
IMPORT_RE = re.compile(r"@import\s+(.+?)\s*;", re.DOTALL)
25+
26+
def __init__(
27+
self,
28+
executable: str = "sass",
29+
sourcemap_enabled: bool = False,
30+
compass_enabled: bool = False,
31+
load_paths: Optional[StrCollection] = None,
32+
precision: Optional[int] = None,
33+
output_style: Optional[str] = None,
34+
):
35+
self.executable = executable
36+
self.is_sourcemap_enabled = sourcemap_enabled
37+
self.is_compass_enabled = compass_enabled
38+
self.precision = precision
39+
self.output_style = output_style
40+
self.load_paths: StrCollection = load_paths or []
41+
super().__init__()
42+
43+
def get_extra_args(self) -> List[str]:
44+
args = []
45+
46+
for path in self.load_paths:
47+
args += ["-I", path]
48+
49+
if self.is_compass_enabled:
50+
args.append("--compass")
51+
52+
if self.precision:
53+
args += ["--precision", str(self.precision)]
54+
55+
if self.output_style:
56+
args += ["-t", self.output_style]
57+
58+
return args
59+
60+
def should_compile(self, source_path: str, from_management: bool = False) -> bool:
61+
# Do not compile the files that start with "_" if run from management
62+
if from_management and os.path.basename(source_path).startswith("_"):
63+
return False
64+
return super().should_compile(source_path, from_management)
65+
66+
def compile_file(self, source_path: str) -> str:
67+
full_source_path = self.get_full_source_path(source_path)
68+
full_output_path = self.get_full_output_path(source_path)
69+
args = [
70+
self.executable,
71+
"--sourcemap={}".format("auto" if self.is_sourcemap_enabled else "none"),
72+
] + self.get_extra_args()
73+
74+
args.extend(
75+
[
76+
self.get_full_source_path(source_path),
77+
full_output_path,
78+
]
79+
)
80+
81+
full_output_dirname = os.path.dirname(full_output_path)
82+
if not os.path.exists(full_output_dirname):
83+
os.makedirs(full_output_dirname)
84+
85+
# `cwd` is a directory containing `source_path`.
86+
# Ex: source_path = '1/2/3', full_source_path = '/abc/1/2/3' -> cwd = '/abc'
87+
cwd = os.path.normpath(os.path.join(full_source_path, *([".."] * len(source_path.split("/")))))
88+
return_code, out, errors = utils.run_command(args, cwd=cwd)
89+
90+
if return_code:
91+
if os.path.exists(full_output_path):
92+
os.remove(full_output_path)
93+
raise exceptions.StaticCompilationError(errors)
94+
95+
url_converter.convert_urls(full_output_path, source_path)
96+
97+
if self.is_sourcemap_enabled:
98+
utils.fix_sourcemap(full_output_path + ".map", source_path, full_output_path)
99+
100+
return self.get_output_path(source_path)
101+
102+
def compile_source(self, source: str) -> str:
103+
args = [
104+
self.executable,
105+
"-s",
106+
] + self.get_extra_args()
107+
108+
if self.executable.endswith("sass"):
109+
args.append("--scss")
110+
111+
return_code, out, errors = utils.run_command(args, input=source)
112+
if return_code:
113+
raise exceptions.StaticCompilationError(errors)
114+
115+
return out
116+
117+
# noinspection PyMethodMayBeStatic
118+
def parse_import_string(self, import_string: str) -> List[str]:
119+
"""Extract import items from import string.
120+
:param import_string: import string
121+
"""
122+
items = []
123+
item = ""
124+
in_quotes = False
125+
quote = ""
126+
in_parentheses = False
127+
item_allowed = True
128+
129+
for char in import_string:
130+
131+
if char == ")":
132+
in_parentheses = False
133+
continue
134+
135+
if in_parentheses:
136+
continue
137+
138+
if char == "(":
139+
item = ""
140+
in_parentheses = True
141+
continue
142+
143+
if char == ",":
144+
if in_quotes:
145+
item += char
146+
else:
147+
if item:
148+
items.append(item)
149+
item = ""
150+
item_allowed = True
151+
continue
152+
153+
if char in " \t\n\r\f\v":
154+
if in_quotes:
155+
item += char
156+
elif item:
157+
items.append(item)
158+
item_allowed = False
159+
item = ""
160+
continue
161+
162+
if char in "\"'":
163+
if in_quotes:
164+
if char == quote:
165+
# Close quote
166+
in_quotes = False
167+
else:
168+
item += char
169+
else:
170+
in_quotes = True
171+
quote = char
172+
continue
173+
174+
if not item_allowed:
175+
break
176+
177+
item += char
178+
179+
if item:
180+
items.append(item)
181+
182+
return sorted(items)
183+
184+
def strip_comments(self, source: str) -> str:
185+
"""Strip comments from source, it does not remove comments inside
186+
strings or comments inside functions calls.
187+
188+
Contribution taken from and added function call pattern
189+
https://stackoverflow.com/questions/2319019/using-regex-to-remove-comments-from-source-files
190+
191+
:param source: source code
192+
"""
193+
pattern = r"(\".*?\"|\'.*?\'|\(.*?\))|(\s*/\*.*?\*/|\s*//[^\r\n]*$)"
194+
# first group captures quoted sources (double or single)
195+
# second group captures comments (//single-line or /* multi-line */)
196+
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
197+
198+
def _replacer(match: Match[str]) -> str:
199+
# if the 2nd group (capturing comments) is not None,
200+
# it means we have captured a non-quoted (real) comment source.
201+
if match.group(2) is not None:
202+
return "" # so we will return empty to remove the comment
203+
else: # otherwise, we will return the 1st group
204+
return match.group(1) # captured quoted-source or function call
205+
206+
return regex.sub(_replacer, source)
207+
208+
def find_imports(self, source: str) -> List[str]:
209+
"""Find the imported files in the source code.
210+
211+
:param source: source code
212+
"""
213+
source = self.strip_comments(source)
214+
imports = set()
215+
for import_string in self.IMPORT_RE.findall(source):
216+
for import_item in self.parse_import_string(import_string):
217+
import_item = import_item.strip()
218+
if not import_item:
219+
continue
220+
if import_item.endswith(".css"):
221+
continue
222+
if import_item.startswith("http://") or import_item.startswith("https://"):
223+
continue
224+
if self.is_compass_enabled and (
225+
import_item in ("compass", "compass.scss") or import_item.startswith("compass/")
226+
):
227+
# Ignore compass imports if Compass is enabled.
228+
continue
229+
imports.add(import_item)
230+
return sorted(imports)
231+
232+
def get_full_source_path(self, source_path: str) -> str:
233+
try:
234+
return super().get_full_source_path(source_path)
235+
except ValueError:
236+
# Try to locate the source file in directories specified in `load_paths`
237+
norm_source_path = utils.normalize_path(source_path.lstrip("/"))
238+
for dirname in self.load_paths:
239+
full_path = os.path.join(dirname, norm_source_path)
240+
if os.path.exists(full_path):
241+
return full_path
242+
raise
243+
244+
def locate_imported_file(self, source_dir: str, import_path: str) -> str:
245+
"""Locate the imported file in the source directory.
246+
Return the path to the imported file relative to STATIC_ROOT
247+
248+
:param source_dir: source directory
249+
:param import_path: path to the imported file
250+
"""
251+
import_filename = posixpath.basename(import_path)
252+
import_dirname = posixpath.dirname(import_path)
253+
import_filename_root, import_filename_extension = posixpath.splitext(import_filename)
254+
255+
if import_filename_extension:
256+
filenames_to_try = [import_filename]
257+
else:
258+
# No extension is specified for the imported file, try all supported extensions
259+
filenames_to_try = [import_filename_root + "." + extension for extension in self.import_extensions]
260+
261+
if not import_filename.startswith("_"):
262+
# Try the files with "_" prefix
263+
filenames_to_try += ["_" + filename for filename in filenames_to_try]
264+
265+
# Try to locate the file in the directory relative to `source_dir`
266+
for filename in filenames_to_try:
267+
source_path = posixpath.normpath(posixpath.join(source_dir, import_dirname, filename))
268+
try:
269+
self.get_full_source_path(source_path)
270+
return source_path
271+
except ValueError:
272+
pass
273+
274+
# Try to locate the file in the directories listed in `load_paths`
275+
for dirname in self.load_paths:
276+
for filename in filenames_to_try:
277+
source_path = posixpath.join(import_dirname, filename)
278+
if os.path.exists(os.path.join(dirname, utils.normalize_path(source_path))):
279+
return source_path
280+
281+
raise exceptions.StaticCompilationError(f"Can't locate the imported file: {import_path}")
282+
283+
def find_dependencies(self, source_path: str) -> List[str]:
284+
source = self.get_source(source_path)
285+
source_dir = posixpath.dirname(source_path)
286+
dependencies = set()
287+
for import_path in self.find_imports(source):
288+
import_path = self.locate_imported_file(source_dir, import_path)
289+
dependencies.add(import_path)
290+
dependencies.update(self.find_dependencies(import_path))
291+
return sorted(dependencies)
292+
293+
294+
# noinspection PyAbstractClass
295+
class SASS(SCSS):
296+
297+
name = "sass"
298+
input_extension = "sass"
299+
import_extensions = ("sass", "scss")
300+
301+
IMPORT_RE = re.compile(r"@import\s+(.+?)\s*(?:\n|$)")
302+
303+
def compile_source(self, source: str) -> str:
304+
args = [
305+
self.executable,
306+
"-s",
307+
] + self.get_extra_args()
308+
if self.executable.endswith("scss"):
309+
args.append("--sass")
310+
311+
return_code, out, errors = utils.run_command(args, input=source)
312+
if return_code:
313+
raise exceptions.StaticCompilationError(errors)
314+
315+
return out

0 commit comments

Comments
 (0)