1/*
2 * Copyright (C) 2012 Google Inc. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are
6 * met:
7 *
8 *     * Redistributions of source code must retain the above copyright
9 * notice, this list of conditions and the following disclaimer.
10 *     * Redistributions in binary form must reproduce the above
11 * copyright notice, this list of conditions and the following disclaimer
12 * in the documentation and/or other materials provided with the
13 * distribution.
14 *     * Neither the name of Google Inc. nor the names of its
15 * contributors may be used to endorse or promote products derived from
16 * this software without specific prior written permission.
17 *
18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 */
30
31/**
32 * @constructor
33 * @implements {WebInspector.SourceMapping}
34 * @param {!WebInspector.CSSStyleModel} cssModel
35 * @param {!WebInspector.Workspace} workspace
36 * @param {!WebInspector.SimpleWorkspaceProvider} networkWorkspaceProvider
37 */
38WebInspector.SASSSourceMapping = function(cssModel, workspace, networkWorkspaceProvider)
39{
40    this.pollPeriodMs = 5000;
41    this.pollIntervalMs = 200;
42
43    this._cssModel = cssModel;
44    this._workspace = workspace;
45    this._networkWorkspaceProvider = networkWorkspaceProvider;
46    this._addingRevisionCounter = 0;
47    this._reset();
48    WebInspector.fileManager.addEventListener(WebInspector.FileManager.EventTypes.SavedURL, this._fileSaveFinished, this);
49    WebInspector.settings.cssSourceMapsEnabled.addChangeListener(this._toggleSourceMapSupport, this)
50    this._cssModel.addEventListener(WebInspector.CSSStyleModel.Events.StyleSheetChanged, this._styleSheetChanged, this);
51    this._workspace.addEventListener(WebInspector.Workspace.Events.UISourceCodeAdded, this._uiSourceCodeAdded, this);
52    this._workspace.addEventListener(WebInspector.Workspace.Events.UISourceCodeContentCommitted, this._uiSourceCodeContentCommitted, this);
53    this._workspace.addEventListener(WebInspector.Workspace.Events.ProjectWillReset, this._reset, this);
54}
55
56WebInspector.SASSSourceMapping.prototype = {
57    /**
58     * @param {!WebInspector.Event} event
59     */
60    _styleSheetChanged: function(event)
61    {
62        var id = /** @type {!CSSAgent.StyleSheetId} */ (event.data.styleSheetId);
63        if (this._addingRevisionCounter) {
64            --this._addingRevisionCounter;
65            return;
66        }
67        var header = this._cssModel.styleSheetHeaderForId(id);
68        if (!header)
69            return;
70
71        this.removeHeader(header);
72    },
73
74    /**
75     * @param {!WebInspector.Event} event
76     */
77    _toggleSourceMapSupport: function(event)
78    {
79        var enabled = /** @type {boolean} */ (event.data);
80        var headers = this._cssModel.styleSheetHeaders();
81        for (var i = 0; i < headers.length; ++i) {
82            if (enabled)
83                this.addHeader(headers[i]);
84            else
85                this.removeHeader(headers[i]);
86        }
87    },
88
89    /**
90     * @param {!WebInspector.Event} event
91     */
92    _fileSaveFinished: function(event)
93    {
94        var sassURL = /** @type {string} */ (event.data);
95        this._sassFileSaved(sassURL, false);
96    },
97
98    /**
99     * @param {string} headerName
100     * @param {!NetworkAgent.Headers} headers
101     * @return {?string}
102     */
103    _headerValue: function(headerName, headers)
104    {
105        headerName = headerName.toLowerCase();
106        var value = null;
107        for (var name in headers) {
108            if (name.toLowerCase() === headerName) {
109                value = headers[name];
110                break;
111            }
112        }
113        return value;
114    },
115
116    /**
117     * @param {!NetworkAgent.Headers} headers
118     * @return {?Date}
119     */
120    _lastModified: function(headers)
121    {
122        var lastModifiedHeader = this._headerValue("last-modified", headers);
123        if (!lastModifiedHeader)
124            return null;
125        var lastModified = new Date(lastModifiedHeader);
126        if (isNaN(lastModified.getTime()))
127            return null;
128        return lastModified;
129    },
130
131    /**
132     * @param {!NetworkAgent.Headers} headers
133     * @param {string} url
134     * @return {?Date}
135     */
136    _checkLastModified: function(headers, url)
137    {
138        var lastModified = this._lastModified(headers);
139        if (lastModified)
140            return lastModified;
141
142        var etagMessage = this._headerValue("etag", headers) ? ", \"ETag\" response header found instead" : "";
143        var message = String.sprintf("The \"Last-Modified\" response header is missing or invalid for %s%s. The CSS auto-reload functionality will not work correctly.", url, etagMessage);
144        WebInspector.log(message);
145        return null;
146    },
147
148    /**
149     * @param {string} sassURL
150     * @param {boolean} wasLoadedFromFileSystem
151     */
152    _sassFileSaved: function(sassURL, wasLoadedFromFileSystem)
153    {
154        var cssURLs = this._cssURLsForSASSURL[sassURL];
155        if (!cssURLs)
156            return;
157        if (!WebInspector.settings.cssReloadEnabled.get())
158            return;
159
160        var sassFile = this._workspace.uiSourceCodeForURL(sassURL);
161        console.assert(sassFile);
162        if (wasLoadedFromFileSystem)
163            sassFile.requestMetadata(metadataReceived.bind(this));
164        else
165            NetworkAgent.loadResourceForFrontend(WebInspector.resourceTreeModel.mainFrame.id, sassURL, undefined, sassLoadedViaNetwork.bind(this));
166
167        /**
168         * @param {?Protocol.Error} error
169         * @param {number} statusCode
170         * @param {!NetworkAgent.Headers} headers
171         * @param {string} content
172         * @this {WebInspector.SASSSourceMapping}
173         */
174        function sassLoadedViaNetwork(error, statusCode, headers, content)
175        {
176            if (error || statusCode >= 400) {
177                console.error("Could not load content for " + sassURL + " : " + (error || ("HTTP status code: " + statusCode)));
178                return;
179            }
180            var lastModified = this._checkLastModified(headers, sassURL);
181            if (!lastModified)
182                return;
183            metadataReceived.call(this, lastModified);
184        }
185
186        /**
187         * @param {?Date} timestamp
188         * @this {WebInspector.SASSSourceMapping}
189         */
190        function metadataReceived(timestamp)
191        {
192            if (!timestamp)
193                return;
194
195            var now = Date.now();
196            var deadlineMs = now + this.pollPeriodMs;
197            var pollData = this._pollDataForSASSURL[sassURL];
198            if (pollData) {
199                var dataByURL = pollData.dataByURL;
200                for (var url in dataByURL)
201                    clearTimeout(dataByURL[url].timer);
202            }
203            pollData = { dataByURL: {}, deadlineMs: deadlineMs, sassTimestamp: timestamp };
204            this._pollDataForSASSURL[sassURL] = pollData;
205            for (var i = 0; i < cssURLs.length; ++i) {
206                pollData.dataByURL[cssURLs[i]] = { previousPoll: now };
207                this._pollCallback(cssURLs[i], sassURL, false);
208            }
209        }
210    },
211
212    /**
213     * @param {string} cssURL
214     * @param {string} sassURL
215     * @param {boolean} stopPolling
216     */
217    _pollCallback: function(cssURL, sassURL, stopPolling)
218    {
219        var now;
220        var pollData = this._pollDataForSASSURL[sassURL];
221        if (!pollData)
222            return;
223
224        if (stopPolling || (now = new Date().getTime()) > pollData.deadlineMs) {
225            delete pollData.dataByURL[cssURL];
226            if (!Object.keys(pollData.dataByURL).length)
227                delete this._pollDataForSASSURL[sassURL];
228            return;
229        }
230        var nextPoll = this.pollIntervalMs + pollData.dataByURL[cssURL].previousPoll;
231        var remainingTimeoutMs = Math.max(0, nextPoll - now);
232        pollData.dataByURL[cssURL].previousPoll = now + remainingTimeoutMs;
233        pollData.dataByURL[cssURL].timer = setTimeout(this._reloadCSS.bind(this, cssURL, sassURL, this._pollCallback.bind(this)), remainingTimeoutMs);
234    },
235
236    /**
237     * @param {string} cssURL
238     * @param {string} sassURL
239     * @param {function(string, string, boolean)} callback
240     */
241    _reloadCSS: function(cssURL, sassURL, callback)
242    {
243        var cssUISourceCode = this._workspace.uiSourceCodeForURL(cssURL);
244        if (!cssUISourceCode) {
245            WebInspector.log(cssURL + " resource missing. Please reload the page.");
246            callback(cssURL, sassURL, true);
247            return;
248        }
249
250        if (this._workspace.hasMappingForURL(sassURL))
251            this._reloadCSSFromFileSystem(cssUISourceCode, sassURL, callback);
252        else
253            this._reloadCSSFromNetwork(cssUISourceCode, sassURL, callback);
254    },
255
256    /**
257     * @param {!WebInspector.UISourceCode} cssUISourceCode
258     * @param {string} sassURL
259     * @param {function(string, string, boolean)} callback
260     */
261    _reloadCSSFromNetwork: function(cssUISourceCode, sassURL, callback)
262    {
263        var cssURL = cssUISourceCode.url;
264        var data = this._pollDataForSASSURL[sassURL];
265        if (!data) {
266            callback(cssURL, sassURL, true);
267            return;
268        }
269        var headers = { "if-modified-since": new Date(data.sassTimestamp.getTime() - 1000).toUTCString() };
270        NetworkAgent.loadResourceForFrontend(WebInspector.resourceTreeModel.mainFrame.id, cssURL, headers, contentLoaded.bind(this));
271
272        /**
273         * @param {?Protocol.Error} error
274         * @param {number} statusCode
275         * @param {!NetworkAgent.Headers} headers
276         * @param {string} content
277         * @this {WebInspector.SASSSourceMapping}
278         */
279        function contentLoaded(error, statusCode, headers, content)
280        {
281            if (error || statusCode >= 400) {
282                console.error("Could not load content for " + cssURL + " : " + (error || ("HTTP status code: " + statusCode)));
283                callback(cssURL, sassURL, true);
284                return;
285            }
286            if (!this._pollDataForSASSURL[sassURL]) {
287                callback(cssURL, sassURL, true);
288                return;
289            }
290            if (statusCode === 304) {
291                callback(cssURL, sassURL, false);
292                return;
293            }
294            var lastModified = this._checkLastModified(headers, cssURL);
295            if (!lastModified) {
296                callback(cssURL, sassURL, true);
297                return;
298            }
299            if (lastModified.getTime() < data.sassTimestamp.getTime()) {
300                callback(cssURL, sassURL, false);
301                return;
302            }
303            this._updateCSSRevision(cssUISourceCode, content, sassURL, callback);
304        }
305    },
306
307    /**
308     * @param {!WebInspector.UISourceCode} cssUISourceCode
309     * @param {string} content
310     * @param {string} sassURL
311     * @param {function(string, string, boolean)} callback
312     */
313    _updateCSSRevision: function(cssUISourceCode, content, sassURL, callback)
314    {
315        ++this._addingRevisionCounter;
316        cssUISourceCode.addRevision(content);
317        this._cssUISourceCodeUpdated(cssUISourceCode.url, sassURL, callback);
318    },
319
320    /**
321     * @param {!WebInspector.UISourceCode} cssUISourceCode
322     * @param {string} sassURL
323     * @param {function(string, string, boolean)} callback
324     */
325    _reloadCSSFromFileSystem: function(cssUISourceCode, sassURL, callback)
326    {
327        cssUISourceCode.requestMetadata(metadataCallback.bind(this));
328
329        /**
330         * @param {?Date} timestamp
331         * @this {WebInspector.SASSSourceMapping}
332         */
333        function metadataCallback(timestamp)
334        {
335            var cssURL = cssUISourceCode.url;
336            if (!timestamp) {
337                callback(cssURL, sassURL, false);
338                return;
339            }
340            var cssTimestamp = timestamp.getTime();
341            var pollData = this._pollDataForSASSURL[sassURL];
342            if (!pollData) {
343                callback(cssURL, sassURL, true);
344                return;
345            }
346
347            if (cssTimestamp < pollData.sassTimestamp.getTime()) {
348                callback(cssURL, sassURL, false);
349                return;
350            }
351
352            cssUISourceCode.requestOriginalContent(contentCallback.bind(this));
353
354            /**
355             * @param {?string} content
356             * @this {WebInspector.SASSSourceMapping}
357             */
358            function contentCallback(content)
359            {
360                // Empty string is a valid value, null means error.
361                if (content === null)
362                    return;
363                this._updateCSSRevision(cssUISourceCode, content, sassURL, callback);
364            }
365        }
366    },
367
368    /**
369     * @param {string} cssURL
370     * @param {string} sassURL
371     * @param {function(string, string, boolean)} callback
372     */
373    _cssUISourceCodeUpdated: function(cssURL, sassURL, callback)
374    {
375        var completeSourceMapURL = this._completeSourceMapURLForCSSURL[cssURL];
376        if (!completeSourceMapURL)
377            return;
378        var ids = this._cssModel.styleSheetIdsForURL(cssURL);
379        if (!ids)
380            return;
381        var headers = [];
382        for (var i = 0; i < ids.length; ++i)
383            headers.push(this._cssModel.styleSheetHeaderForId(ids[i]));
384        for (var i = 0; i < ids.length; ++i)
385            this._loadSourceMapAndBindUISourceCode(headers, true, completeSourceMapURL);
386        callback(cssURL, sassURL, true);
387    },
388
389    /**
390     * @param {!WebInspector.CSSStyleSheetHeader} header
391     */
392    addHeader: function(header)
393    {
394        if (!header.sourceMapURL || !header.sourceURL || header.isInline || !WebInspector.settings.cssSourceMapsEnabled.get())
395            return;
396        var completeSourceMapURL = WebInspector.ParsedURL.completeURL(header.sourceURL, header.sourceMapURL);
397        if (!completeSourceMapURL)
398            return;
399        this._completeSourceMapURLForCSSURL[header.sourceURL] = completeSourceMapURL;
400        this._loadSourceMapAndBindUISourceCode([header], false, completeSourceMapURL);
401    },
402
403    /**
404     * @param {!WebInspector.CSSStyleSheetHeader} header
405     */
406    removeHeader: function(header)
407    {
408        var sourceURL = header.sourceURL;
409        if (!sourceURL || !header.sourceMapURL || header.isInline || !this._completeSourceMapURLForCSSURL[sourceURL])
410            return;
411        delete this._sourceMapByStyleSheetURL[sourceURL];
412        delete this._completeSourceMapURLForCSSURL[sourceURL];
413        for (var sassURL in this._cssURLsForSASSURL) {
414            var urls = this._cssURLsForSASSURL[sassURL];
415            urls.remove(sourceURL);
416            if (!urls.length)
417                delete this._cssURLsForSASSURL[sassURL];
418        }
419        var completeSourceMapURL = WebInspector.ParsedURL.completeURL(sourceURL, header.sourceMapURL);
420        if (completeSourceMapURL)
421            delete this._sourceMapByURL[completeSourceMapURL];
422        header.updateLocations();
423    },
424
425    /**
426     * @param {!Array.<!WebInspector.CSSStyleSheetHeader>} headersWithSameSourceURL
427     * @param {boolean} forceRebind
428     * @param {string} completeSourceMapURL
429     */
430    _loadSourceMapAndBindUISourceCode: function(headersWithSameSourceURL, forceRebind, completeSourceMapURL)
431    {
432        console.assert(headersWithSameSourceURL.length);
433        var sourceURL = headersWithSameSourceURL[0].sourceURL;
434        this._loadSourceMapForStyleSheet(completeSourceMapURL, sourceURL, forceRebind, sourceMapLoaded.bind(this));
435
436        /**
437         * @param {?WebInspector.SourceMap} sourceMap
438         * @this {WebInspector.SASSSourceMapping}
439         */
440        function sourceMapLoaded(sourceMap)
441        {
442            if (!sourceMap)
443                return;
444
445            this._sourceMapByStyleSheetURL[sourceURL] = sourceMap;
446            for (var i = 0; i < headersWithSameSourceURL.length; ++i) {
447                if (forceRebind)
448                    headersWithSameSourceURL[i].updateLocations();
449                else
450                    this._bindUISourceCode(headersWithSameSourceURL[i], sourceMap);
451            }
452        }
453    },
454
455    /**
456     * @param {string} cssURL
457     * @param {string} sassURL
458     */
459    _addCSSURLforSASSURL: function(cssURL, sassURL)
460    {
461        var cssURLs;
462        if (this._cssURLsForSASSURL.hasOwnProperty(sassURL))
463            cssURLs = this._cssURLsForSASSURL[sassURL];
464        else {
465            cssURLs = [];
466            this._cssURLsForSASSURL[sassURL] = cssURLs;
467        }
468        if (cssURLs.indexOf(cssURL) === -1)
469            cssURLs.push(cssURL);
470    },
471
472    /**
473     * @param {string} completeSourceMapURL
474     * @param {string} completeStyleSheetURL
475     * @param {boolean} forceReload
476     * @param {function(?WebInspector.SourceMap)} callback
477     */
478    _loadSourceMapForStyleSheet: function(completeSourceMapURL, completeStyleSheetURL, forceReload, callback)
479    {
480        var sourceMap = this._sourceMapByURL[completeSourceMapURL];
481        if (sourceMap && !forceReload) {
482            callback(sourceMap);
483            return;
484        }
485
486        var pendingCallbacks = this._pendingSourceMapLoadingCallbacks[completeSourceMapURL];
487        if (pendingCallbacks) {
488            pendingCallbacks.push(callback);
489            return;
490        }
491
492        pendingCallbacks = [callback];
493        this._pendingSourceMapLoadingCallbacks[completeSourceMapURL] = pendingCallbacks;
494
495        WebInspector.SourceMap.load(completeSourceMapURL, completeStyleSheetURL, sourceMapLoaded.bind(this));
496
497        /**
498         * @param {?WebInspector.SourceMap} sourceMap
499         * @this {WebInspector.SASSSourceMapping}
500         */
501        function sourceMapLoaded(sourceMap)
502        {
503            var callbacks = this._pendingSourceMapLoadingCallbacks[completeSourceMapURL];
504            delete this._pendingSourceMapLoadingCallbacks[completeSourceMapURL];
505            if (!callbacks)
506                return;
507            if (sourceMap)
508                this._sourceMapByURL[completeSourceMapURL] = sourceMap;
509            else
510                delete this._sourceMapByURL[completeSourceMapURL];
511            for (var i = 0; i < callbacks.length; ++i)
512                callbacks[i](sourceMap);
513        }
514    },
515
516    /**
517     * @param {!WebInspector.CSSStyleSheetHeader} header
518     * @param {!WebInspector.SourceMap} sourceMap
519     */
520    _bindUISourceCode: function(header, sourceMap)
521    {
522        header.pushSourceMapping(this);
523        var rawURL = header.sourceURL;
524        var sources = sourceMap.sources();
525        for (var i = 0; i < sources.length; ++i) {
526            var url = sources[i];
527            this._addCSSURLforSASSURL(rawURL, url);
528            if (!this._workspace.hasMappingForURL(url) && !this._workspace.uiSourceCodeForURL(url)) {
529                var contentProvider = sourceMap.sourceContentProvider(url, WebInspector.resourceTypes.Stylesheet);
530                this._networkWorkspaceProvider.addFileForURL(url, contentProvider, true);
531            }
532        }
533    },
534
535    /**
536     * @param {!WebInspector.RawLocation} rawLocation
537     * @return {?WebInspector.UILocation}
538     */
539    rawLocationToUILocation: function(rawLocation)
540    {
541        var location = /** @type WebInspector.CSSLocation */ (rawLocation);
542        var entry;
543        var sourceMap = this._sourceMapByStyleSheetURL[location.url];
544        if (!sourceMap)
545            return null;
546        entry = sourceMap.findEntry(location.lineNumber, location.columnNumber);
547        if (!entry || entry.length === 2)
548            return null;
549        var uiSourceCode = this._workspace.uiSourceCodeForURL(entry[2]);
550        if (!uiSourceCode)
551            return null;
552        return new WebInspector.UILocation(uiSourceCode, entry[3], entry[4]);
553    },
554
555    /**
556     * @param {!WebInspector.UISourceCode} uiSourceCode
557     * @param {number} lineNumber
558     * @param {number} columnNumber
559     * @return {!WebInspector.RawLocation}
560     */
561    uiLocationToRawLocation: function(uiSourceCode, lineNumber, columnNumber)
562    {
563        // FIXME: Implement this when ui -> raw mapping has clients.
564        return new WebInspector.CSSLocation(uiSourceCode.url || "", lineNumber, columnNumber);
565    },
566
567    /**
568     * @param {!WebInspector.Event} event
569     */
570    _uiSourceCodeAdded: function(event)
571    {
572        var uiSourceCode = /** @type {!WebInspector.UISourceCode} */ (event.data);
573        var cssURLs = this._cssURLsForSASSURL[uiSourceCode.url];
574        if (!cssURLs)
575            return;
576        for (var i = 0; i < cssURLs.length; ++i) {
577            var ids = this._cssModel.styleSheetIdsForURL(cssURLs[i]);
578            for (var j = 0; j < ids.length; ++j) {
579                var header = this._cssModel.styleSheetHeaderForId(ids[j]);
580                console.assert(header);
581                header.updateLocations();
582            }
583        }
584    },
585
586    /**
587     * @param {!WebInspector.Event} event
588     */
589    _uiSourceCodeContentCommitted: function(event)
590    {
591        var uiSourceCode = /** @type {!WebInspector.UISourceCode} */ (event.data.uiSourceCode);
592        if (uiSourceCode.project().type() === WebInspector.projectTypes.FileSystem)
593            this._sassFileSaved(uiSourceCode.url, true);
594    },
595
596    _reset: function()
597    {
598        this._addingRevisionCounter = 0;
599        this._completeSourceMapURLForCSSURL = {};
600        this._cssURLsForSASSURL = {};
601        /** @type {!Object.<string, !Array.<function(?WebInspector.SourceMap)>>} */
602        this._pendingSourceMapLoadingCallbacks = {};
603        /** @type {!Object.<string, {deadlineMs: number, dataByURL: !Object.<string, !{timer: number, previousPoll: number}>}>} */
604        this._pollDataForSASSURL = {};
605        /** @type {!Object.<string, !WebInspector.SourceMap>} */
606        this._sourceMapByURL = {};
607        this._sourceMapByStyleSheetURL = {};
608    }
609}
610