1   package org.daisy.pipeline.css.sass.impl;
2   
3   import java.io.IOException;
4   import java.io.InputStream;
5   import java.io.InputStreamReader;
6   import java.io.Reader;
7   import java.io.UncheckedIOException;
8   import java.net.URI;
9   import java.net.URL;
10  import java.nio.charset.Charset;
11  import java.nio.charset.StandardCharsets;
12  import java.util.ArrayList;
13  import java.util.Collection;
14  import java.util.Comparator;
15  import java.util.Iterator;
16  import java.util.List;
17  import java.util.Set;
18  import java.util.TreeSet;
19  
20  import javax.xml.parsers.DocumentBuilderFactory;
21  import javax.xml.parsers.ParserConfigurationException;
22  import javax.xml.transform.dom.DOMSource;
23  import javax.xml.transform.Source;
24  import javax.xml.transform.TransformerException;
25  import javax.xml.transform.URIResolver;
26  import javax.xml.transform.sax.SAXSource;
27  import javax.xml.transform.stream.StreamSource;
28  
29  import com.google.common.collect.Iterables;
30  import com.google.common.io.CharStreams;
31  
32  import cz.vutbr.web.css.NetworkProcessor;
33  import cz.vutbr.web.csskit.DefaultNetworkProcessor;
34  import cz.vutbr.web.csskit.antlr.CSSSource;
35  import cz.vutbr.web.csskit.antlr.CSSSourceReader;
36  import cz.vutbr.web.csskit.antlr.DefaultCSSSourceReader;
37  import cz.vutbr.web.domassign.GenericTreeWalker;
38  import cz.vutbr.web.csskit.antlr.CSSSourceReader.CSSInputStream;
39  
40  import org.antlr.runtime.ANTLRReaderStream;
41  import org.antlr.runtime.CharStream;
42  import org.antlr.runtime.CommonTokenStream;
43  import org.antlr.runtime.RecognitionException;
44  
45  import org.daisy.common.file.URLs;
46  import org.daisy.pipeline.css.Medium;
47  import org.daisy.pipeline.datatypes.DatatypeRegistry;
48  
49  import org.slf4j.Logger;
50  import org.slf4j.LoggerFactory;
51  
52  import org.w3c.dom.Attr;
53  import org.w3c.dom.CharacterData;
54  import org.w3c.dom.Document;
55  import org.w3c.dom.Element;
56  import org.w3c.dom.Node;
57  import org.w3c.dom.NodeList;
58  import org.w3c.dom.traversal.DocumentTraversal;
59  import org.w3c.dom.traversal.NodeFilter;
60  import org.w3c.dom.traversal.TreeWalker;
61  
62  import org.xml.sax.InputSource;
63  import org.xml.sax.SAXException;
64  
65  public class SassAnalyzer {
66  
67  	private static final Logger logger = LoggerFactory.getLogger(SassAnalyzer.class.getName());
68  
69  	private final Collection<Medium> media;
70  	private final CSSSourceReader cssReader;
71  	final DatatypeRegistry datatypes; // also used in SassDocumentationParser
72  
73  	public SassAnalyzer(Collection<Medium> media, URIResolver uriResolver, DatatypeRegistry datatypes) {
74  		this.media = media;
75  		this.datatypes = datatypes;
76  		NetworkProcessor network = new DefaultNetworkProcessor() {
77  				@Override
78  				public Reader fetch(URL url, Charset encoding, boolean forceEncoding, boolean assertEncoding)
79  					throws IOException {
80  
81  					logger.debug("Fetching style sheet: " + url);
82  					if (uriResolver != null) {
83  						Source resolved; {
84  							try {
85  								resolved = uriResolver.resolve(URLs.asURI(url).toASCIIString(), ""); }
86  							catch (TransformerException e) {
87  								throw new IOException(e); }}
88  						if (resolved != null) {
89  							if (resolved instanceof StreamSource) {
90  								InputStreamReader r = detectEncodingAndSkipBOM(
91  									((StreamSource)resolved).getInputStream(), null, encoding, forceEncoding);
92  								if (assertEncoding) {
93  									if (encoding == null)
94  										throw new IllegalArgumentException("encoding must not be null");
95  									if (!encoding.equals(getEncoding(r)))
96  										throw new IOException("Failed to read URL as " + encoding + ": " + url);
97  								}
98  								return r;
99  							} else {
100 								url = new URL(resolved.getSystemId());
101 							}
102 						}
103 					}
104 					return super.fetch(url, encoding, forceEncoding, assertEncoding);
105 				}
106 			};
107 		cssReader = new DefaultCSSSourceReader(network) {
108 				@Override
109 				public boolean supportsMediaType(String mediaType, URL url) {
110 					if ("text/css".equals(mediaType) || "text/x-scss".equals(mediaType))
111 						return true;
112 					else if (mediaType == null
113 					         && (url == null || url.toString().endsWith(".css") || url.toString().endsWith(".scss")))
114 						return true;
115 					else
116 						return false;
117 				}
118 				// the returned CSSInputStream contains the original (unprocessed) file
119 				@Override
120 				public CSSInputStream read(CSSSource source) throws IOException {
121 					if (source.type == CSSSource.SourceType.URL) {
122 						URL url = (URL)source.source;
123 						if (url != null && !url.toString().endsWith(".css") && !url.toString().endsWith(".scss"))
124 							// check whether the resource exists when ".scss" is added
125 							try {
126 								return read(new CSSSource(new URL(url.toString() + ".scss"), source.encoding, source.mediaType));
127 							} catch (IOException e) {
128 								// ... or ".css" is added
129 								try {
130 									return read(new CSSSource(new URL(url.toString() + ".css"), source.encoding, source.mediaType));
131 								} catch (IOException ee) {
132 								}
133 								// ... or "_" and ".scss" are added
134 								try {
135 									String fileName = url.toString();
136 									fileName = fileName.substring(fileName.lastIndexOf('/') + 1, fileName.length());
137 									return read(new CSSSource(URLs.asURL(URLs.asURI(url).resolve("_" + fileName + ".scss")),
138 									                          source.encoding,
139 									                          source.mediaType));
140 								} catch (IOException ee) {
141 								}
142 								// ... or "_index.scss" is added
143 								try {
144 									return read(new CSSSource(new URL(url.toString() + "/_index.scss"), source.encoding, source.mediaType));
145 								} catch (IOException ee) {
146 								}
147 								// otherwise fail
148 								throw e;
149 							}
150 						if (uriResolver != null) {
151 							try {
152 								// NetworkProcessor.fetch() also does resolve() but we need an additional resolve() to give
153 								// CSSInputStream the correct base URL
154 								Source resolved = uriResolver.resolve(URLs.asURI(url).toASCIIString(), "");
155 								if (resolved != null)
156 									source = new CSSSource(new URL(resolved.getSystemId()), source.encoding, source.mediaType);
157 							} catch (TransformerException e) {
158 								throw new IOException(e);
159 							}
160 						}
161 					}
162 					return super.read(source);
163 				}
164 		};
165 	}
166 
167 	public Collection<SassVariable> getVariableDeclarations(Iterable<Source> userAndUserAgentStylesheets,
168 	                                                        Source sourceDocument)
169 			throws IOException {
170 
171 		List<CSSSource> stylesheets = new ArrayList<>();
172 		for (Source s : userAndUserAgentStylesheets) {
173 			URL base; {
174 				String systemId = s.getSystemId();
175 				if (systemId == null || "".equals(systemId))
176 					base = null;
177 				else {
178 					URI baseURI = URLs.asURI(systemId);
179 					if (isOpaque(baseURI) || !baseURI.isAbsolute())
180 						throw new IllegalArgumentException("not an absolute hierarchical base URI: " + baseURI);
181 					base = URLs.asURL(baseURI);
182 				}
183 			}
184 			if (s instanceof StreamSource) {
185 				StreamSource ss = (StreamSource)s;
186 				Reader r = ss.getReader();
187 				if (r == null)
188 					r = new InputStreamReader(ss.getInputStream(), StandardCharsets.UTF_8);
189 				stylesheets.add(new CSSSource(CharStreams.toString(r), (String)null, base, 0, 0));
190 			} else {
191 				if (base == null) {
192 					InputSource is = SAXSource.sourceToInputSource(s);
193 					if (is == null)
194 						throw new IllegalArgumentException("unexpected source");
195 					Reader r = is.getCharacterStream();
196 					if (r == null) {
197 						InputStream bs = is.getByteStream();
198 						if (bs != null)
199 							r = new InputStreamReader(bs, StandardCharsets.UTF_8);
200 						else
201 							throw new IllegalArgumentException("unexpected source: no content and no base URI");
202 					}
203 					stylesheets.add(new CSSSource(CharStreams.toString(r), (String)null, base, 0, 0));
204 				} else
205 					stylesheets.add(new CSSSource(base, StandardCharsets.UTF_8, null));
206 			}
207 		}
208 		if (sourceDocument != null) {
209 			try {
210 				Document doc;
211 				URL base; {
212 					if (sourceDocument instanceof DOMSource && ((DOMSource)sourceDocument).getNode() instanceof Document) {
213 						doc = (Document)((DOMSource)sourceDocument).getNode();
214 						URI baseURI = URLs.asURI(doc.getBaseURI());
215 						if (isOpaque(baseURI) || !baseURI.isAbsolute())
216 							throw new IllegalArgumentException("not an absolute hierarchical base URI: " + baseURI);
217 						base = URLs.asURL(baseURI);
218 					} else {
219 						DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
220 						factory.setNamespaceAware(true);
221 						doc = factory.newDocumentBuilder().parse(SAXSource.sourceToInputSource(sourceDocument));
222 						String systemId = sourceDocument.getSystemId();
223 						if (systemId == null || "".equals(systemId))
224 							base = null;
225 						else {
226 							URI baseURI = URLs.asURI(systemId);
227 							if (isOpaque(baseURI) || !baseURI.isAbsolute())
228 								throw new IllegalArgumentException("not an absolute hierarchical base URI: " + baseURI);
229 							base = URLs.asURL(baseURI);
230 						}
231 					}
232 				}
233 				new Traversal(doc) {
234 					@Override
235 					protected void processElement(Element e) {
236 						if ("style".equalsIgnoreCase(e.getNodeName())) {
237 							Attr q = e.getAttributeNode("media");
238 							if (Iterables.any(media, m -> m.matches(q != null ? q.getValue() : null))) {
239 								Attr type = e.getAttributeNode("type");
240 								String mediaType = type != null ? type.getValue().toLowerCase() : null;
241 								if (cssReader.supportsMediaType(mediaType, null))
242 									stylesheets.add(new CSSSource(extractElementText(e), mediaType, base, -1, -1));
243 							}
244 						} else if ("link".equalsIgnoreCase(e.getNodeName())) {
245 							Attr rel = e.getAttributeNode("rel");
246 							if (rel != null && rel.getValue().toLowerCase().contains("stylesheet")) {
247 								Attr href = e.getAttributeNode("href");
248 								if (href != null) {
249 									Attr q = e.getAttributeNode("media");
250 									if (Iterables.any(media, m -> m.matches(q != null ? q.getValue() : null))) {
251 										URL url; {
252 											try {
253 												URI uri = URLs.asURI(href.getValue());
254 												if (base != null)
255 													uri = URLs.resolve(URLs.asURI(base), uri);
256 												url = URLs.asURL(uri);
257 											} catch (RuntimeException ex) {
258 												throw new UncheckedIOException(new IOException(ex));
259 											}
260 										}
261 										Attr type = e.getAttributeNode("type");
262 										String mediaType = type != null ? type.getValue().toLowerCase() : null;
263 										if (cssReader.supportsMediaType(mediaType, url))
264 											stylesheets.add(new CSSSource(url, null, mediaType));
265 									}
266 								}
267 							}
268 						}
269 					}
270 				}.traverse();
271 			} catch (UncheckedIOException e) {
272 				throw e.getCause();
273 			} catch (SAXException|ParserConfigurationException e) {
274 				throw new IOException(e);
275 			}
276 		}
277 		return getVariableDeclarations(stylesheets.toArray(new CSSSource[stylesheets.size()]));
278 	}
279 
280 	Collection<SassVariable> getVariableDeclarations(CSSSource... stylesheets) throws IOException {
281 		List<SassVariable> vars = new ArrayList<>();
282 		try {
283 			for (CSSSource source : stylesheets) {
284 				CSSInputStream s = cssReader.read(source);
285 				CharStream input = new ANTLRReaderStream(s.stream);
286 				SassDocumentationLexer lexer = new SassDocumentationLexer(input);
287 				CommonTokenStream tokens = new CommonTokenStream(lexer);
288 				SassDocumentationParser parser = new SassDocumentationParser(tokens).init(
289 					"text/x-scss".equals(source.mediaType) || (s.base != null && s.base.toString().endsWith(".scss")),
290 					s.base != null ? URLs.asURI(s.base) : null,
291 					media,
292 					this);
293 				vars.addAll(parser.variables());
294 			}
295 		} catch (RecognitionException e) {
296 			throw new RuntimeException("Error happened while parsing the SCSS", e);
297 		}
298 		// drop duplicates
299 		Set<SassVariable> unique = new TreeSet<>(Comparator.comparing(SassVariable::getName));
300 		Iterator<SassVariable> i = vars.iterator();
301 		while (i.hasNext()) {
302 			SassVariable v = i.next();
303 			if (!v.isDefault())
304 				if (!unique.add(v))
305 					i.remove(); }
306 		i = vars.iterator();
307 		while (i.hasNext()) {
308 			SassVariable v = i.next();
309 			if (v.isDefault())
310 				if (!unique.add(v))
311 					i.remove(); }
312 		return vars;
313 	}
314 
315 	private static abstract class Traversal {
316 		private final TreeWalker walker;
317 		protected Traversal(Document doc) {
318 			if (doc instanceof DocumentTraversal)
319 				walker = ((DocumentTraversal)doc).createTreeWalker(doc.getDocumentElement(), NodeFilter.SHOW_ELEMENT, null, false);
320 			else
321 				walker = new GenericTreeWalker(doc.getDocumentElement(), NodeFilter.SHOW_ELEMENT);
322 		}
323 		protected abstract void processElement(Element element);
324 		public void traverse() {
325 			Node current, checkpoint = null;
326 			current = walker.getCurrentNode();
327 			while (current != null) {
328 				checkpoint = walker.getCurrentNode();
329 				processElement((Element)current);
330 				walker.setCurrentNode(checkpoint);
331 				current = walker.nextNode();
332 			}
333 		}
334 	}
335 
336 	private static String extractElementText(Element e) {
337 		StringBuilder s = new StringBuilder();
338 		NodeList children = e.getChildNodes();
339 		for (int i = 0; i < children.getLength(); i++) {
340 			Node child = children.item(i);
341 			if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE)
342 				s.append(((CharacterData)child).getData());
343 			else if (child.getNodeType() == Node.COMMENT_NODE)
344 				s.append("<!--").append(((CharacterData)child).getData()).append("-->");
345 		}
346 		return s.toString();
347 	}
348 
349 	private static boolean isOpaque(URI uri) {
350 		if (uri.toString().startsWith("jar:file:"))
351 			return uri.isAbsolute() && !uri.getSchemeSpecificPart().substring(5).startsWith("/");
352 		else
353 			return uri.isOpaque();
354 	}
355 }