Web Inspector: CodeMirror 4 CSS mode new state data structure breaks helpers.
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / CodeMirrorAdditions.js
1 /*
2  * Copyright (C) 2013 Apple 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
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 (function () {
27     // By default CodeMirror defines syntax highlighting styles based on token
28     // only and shared styles between modes. This limiting and does not match
29     // what we have done in the Web Inspector. So this modifies the XML, CSS
30     // and JavaScript modes to supply two styles for each token. One for the
31     // token and one with the mode name.
32
33     function tokenizeLinkString(stream, state)
34     {
35         console.assert(state._linkQuoteCharacter !== undefined);
36
37         // Eat the string until the same quote is found that started the string.
38         // If this is unquoted, then eat until whitespace or common parse errors.
39         if (state._linkQuoteCharacter)
40             stream.eatWhile(new RegExp("[^" + state._linkQuoteCharacter + "]"));
41         else
42             stream.eatWhile(/[^\s\u00a0=<>\"\']/);
43
44         // If the stream isn't at the end of line then we found the end quote.
45         // In the case, change _linkTokenize to parse the end of the link next.
46         // Otherwise _linkTokenize will stay as-is to parse more of the link.
47         if (!stream.eol())
48             state._linkTokenize = tokenizeEndOfLinkString;
49
50         return "link";
51     }
52
53     function tokenizeEndOfLinkString(stream, state)
54     {
55         console.assert(state._linkQuoteCharacter !== undefined);
56         console.assert(state._linkBaseStyle);
57
58         // Eat the quote character to style it with the base style.
59         if (state._linkQuoteCharacter)
60             stream.eat(state._linkQuoteCharacter);
61
62         var style = state._linkBaseStyle;
63
64         // Clean up the state.
65         delete state._linkTokenize;
66         delete state._linkQuoteCharacter;
67         delete state._linkBaseStyle;
68
69         return style;
70     }
71
72     function extendedXMLToken(stream, state)
73     {
74         if (state._linkTokenize) {
75             // Call the link tokenizer instead.
76             var style = state._linkTokenize(stream, state);
77             return style && (style + " m-" + this.name);
78         }
79
80         // Remember the start position so we can rewind if needed.
81         var startPosition = stream.pos;
82         var style = this._token(stream, state);
83
84         if (style === "attribute") {
85             // Look for "href" or "src" attributes. If found then we should
86             // expect a string later that should get the "link" style instead.
87             var text = stream.current().toLowerCase();
88             if (text === "href" || text === "src")
89                 state._expectLink = true;
90             else
91                 delete state._expectLink;
92         } else if (state._expectLink && style === "string") {
93             delete state._expectLink;
94
95             // This is a link, so setup the state to process it next.
96             state._linkTokenize = tokenizeLinkString;
97             state._linkBaseStyle = style;
98
99             // The attribute may or may not be quoted.
100             var quote = stream.current()[0];
101             state._linkQuoteCharacter = quote === "'" || quote === "\"" ? quote : null;
102
103             // Rewind the steam to the start of this token.
104             stream.pos = startPosition;
105
106             // Eat the open quote of the string so the string style
107             // will be used for the quote character.
108             if (state._linkQuoteCharacter)
109                 stream.eat(state._linkQuoteCharacter);
110         } else if (style) {
111             // We don't expect other tokens between attribute and string since
112             // spaces and the equal character are not tokenized. So if we get
113             // another token before a string then we stop expecting a link.
114             delete state._expectLink;
115         }
116
117         return style && (style + " m-" + this.name);
118     }
119
120     function tokenizeCSSURLString(stream, state)
121     {
122         console.assert(state._urlQuoteCharacter);
123
124         // If we are an unquoted url string, return whitespace blocks as a whitespace token (null).
125         if (state._unquotedURLString && stream.eatSpace())
126             return null;
127
128         var ch = null;
129         var escaped = false;
130         var reachedEndOfURL = false;
131         var lastNonWhitespace = stream.pos;
132         var quote = state._urlQuoteCharacter;
133
134         // Parse characters until the end of the stream/line or a proper end quote character.
135         while ((ch = stream.next()) != null) {
136             if (ch == quote && !escaped) {
137                 reachedEndOfURL = true;
138                 break;
139             }
140             escaped = !escaped && ch === "\\";
141             if (!/[\s\u00a0]/.test(ch))
142                 lastNonWhitespace = stream.pos;
143         }
144
145         // If we are an unquoted url string, do not include trailing whitespace, rewind to the last real character.
146         if (state._unquotedURLString)
147             stream.pos = lastNonWhitespace;
148
149         // If we have reached the proper the end of the url string, switch to the end tokenizer to reset the state.
150         if (reachedEndOfURL) {
151             if (!state._unquotedURLString)
152                 stream.backUp(1);
153             this._urlTokenize = tokenizeEndOfCSSURLString;
154         }
155
156         return "link";
157     }
158
159     function tokenizeEndOfCSSURLString(stream, state)
160     {
161         console.assert(state._urlQuoteCharacter);
162         console.assert(state._urlBaseStyle);
163
164         // Eat the quote character to style it with the base style.
165         if (!state._unquotedURLString)
166             stream.eat(state._urlQuoteCharacter);
167
168         var style = state._urlBaseStyle;
169
170         delete state._urlTokenize;
171         delete state._urlQuoteCharacter;
172         delete state._urlBaseStyle;
173
174         return style;
175     }
176
177     function extendedCSSToken(stream, state)
178     {
179         if (state._urlTokenize) {
180             // Call the link tokenizer instead.
181             var style = state._urlTokenize(stream, state);
182             return style && (style + " m-" + (this.alternateName || this.name));
183         }
184
185         // Remember the start position so we can rewind if needed.
186         var startPosition = stream.pos;
187         var style = this._token(stream, state);
188
189         if (style) {
190             if (style === "atom" && stream.current() === "url") {
191                 // If the current text is "url" then we should expect the next string token to be a link.
192                 state._expectLink = true;
193             } else if (state._expectLink && style === "atom") {
194                 // We expected a string and got it. This is a link. Parse it the way we want it.
195                 delete state._expectLink;
196
197                 // This is a link, so setup the state to process it next.
198                 state._urlTokenize = tokenizeCSSURLString;
199                 state._urlBaseStyle = style;
200
201                 // The url may or may not be quoted.
202                 var quote = stream.current()[0];
203                 state._urlQuoteCharacter = quote === "'" || quote === "\"" ? quote : ")";
204                 state._unquotedURLString = state._urlQuoteCharacter === ")";
205
206                 // Rewind the steam to the start of this token.
207                 stream.pos = startPosition;
208
209                 // Eat the open quote of the string so the string style
210                 // will be used for the quote character.
211                 if (!state._unquotedURLString)
212                     stream.eat(state._urlQuoteCharacter);
213             } else if (state._expectLink) {
214                 // We expected a string and didn't get one. Cleanup.
215                 delete state._expectLink;
216             }
217         }
218
219         return style && (style + " m-" + (this.alternateName || this.name));
220     }
221
222     function extendedToken(stream, state)
223     {
224         // CodeMirror moves the original token function to _token when we extended it.
225         // So call it to get the style that we will add an additional class name to.
226         var style = this._token(stream, state);
227         return style && (style + " m-" + (this.alternateName || this.name));
228     }
229
230     function extendedCSSRuleStartState(base)
231     {
232         // CodeMirror moves the original token function to _startState when we extended it.
233         // So call it to get the original start state that we will modify.
234         var state = this._startState(base);
235
236         // Start off the state stack like it has already parsed a rule. This causes everything
237         // after to be parsed as properties in a rule.
238         state.state = "block";
239         state.context.type = "block";
240
241         return state;
242     }
243
244     CodeMirror.extendMode("css", {token: extendedCSSToken});
245     CodeMirror.extendMode("xml", {token: extendedXMLToken});
246     CodeMirror.extendMode("javascript", {token: extendedToken});
247
248     CodeMirror.defineMode("css-rule", CodeMirror.modes.css);
249     CodeMirror.extendMode("css-rule", {token: extendedCSSToken, startState: extendedCSSRuleStartState, alternateName: "css"});
250
251     CodeMirror.defineExtension("hasLineClass", function(line, where, className) {
252         // This matches the arguments to addLineClass and removeLineClass.
253         var classProperty = (where === "text" ? "textClass" : (where == "background" ? "bgClass" : "wrapClass"));
254         var lineInfo = this.lineInfo(line);
255         if (!lineInfo)
256             return false;
257
258         if (!lineInfo[classProperty])
259             return false;
260
261         // Test for the simple case.
262         if (lineInfo[classProperty] === className)
263             return true;
264
265         // Do a quick check for the substring. This is faster than a regex, which requires escaping the input first.
266         var index = lineInfo[classProperty].indexOf(className);
267         if (index === -1)
268             return false;
269
270         // Check that it is surrounded by spaces. Add padding spaces first to work with beginning and end of string cases.
271         var paddedClass = " " + lineInfo[classProperty] + " ";
272         return paddedClass.indexOf(" " + className + " ", index) !== -1;
273     });
274
275     CodeMirror.defineExtension("setUniqueBookmark", function(position, options) {
276         var marks = this.findMarksAt(position);
277         for (var i = 0; i < marks.length; ++i) {
278             if (marks[i].__uniqueBookmark) {
279                 marks[i].clear();
280                 break;
281             }
282         }
283
284         var uniqueBookmark = this.setBookmark(position, options);
285         uniqueBookmark.__uniqueBookmark = true;
286         return uniqueBookmark;
287     });
288
289     CodeMirror.defineExtension("toggleLineClass", function(line, where, className) {
290         if (this.hasLineClass(line, where, className)) {
291             this.removeLineClass(line, where, className);
292             return false;
293         }
294
295         this.addLineClass(line, where, className);
296         return true;
297     });
298
299     CodeMirror.defineExtension("alterNumberInRange", function(amount, startPosition, endPosition, updateSelection) {
300         // We don't try if the range is multiline, pass to another key handler.
301         if (startPosition.line !== endPosition.line)
302             return false;
303
304         if (updateSelection) {
305             // Remember the cursor position/selection.
306             var selectionStart = this.getCursor("start");
307             var selectionEnd = this.getCursor("end");
308         }
309
310         var line = this.getLine(startPosition.line);
311
312         var foundPeriod = false;
313
314         var start = NaN;
315         var end = NaN;
316
317         for (var i = startPosition.ch; i >= 0; --i) {
318             var character = line.charAt(i);
319
320             if (character === ".") {
321                 if (foundPeriod)
322                     break;
323                 foundPeriod = true;
324             } else if (character !== "-" && character !== "+" && isNaN(parseInt(character))) {
325                 // Found the end already, just scan backwards.
326                 if (i === startPosition.ch) {
327                     end = i;
328                     continue;
329                 }
330
331                 break;
332             }
333
334             start = i;
335         }
336
337         if (isNaN(end)) {
338             for (var i = startPosition.ch + 1; i < line.length; ++i) {
339                 var character = line.charAt(i);
340
341                 if (character === ".") {
342                     if (foundPeriod) {
343                         end = i;
344                         break;
345                     }
346
347                     foundPeriod = true;
348                 } else if (isNaN(parseInt(character))) {
349                     end = i;
350                     break;
351                 }
352
353                 end = i + 1;
354             }
355         }
356
357         // No number range found, pass to another key handler.
358         if (isNaN(start) || isNaN(end))
359             return false;
360
361         var number = parseFloat(line.substring(start, end));
362
363         // Make the new number and constrain it to a precision of 6, this matches numbers the engine returns.
364         // Use the Number constructor to forget the fixed precision, so 1.100000 will print as 1.1.
365         var alteredNumber = Number((number + amount).toFixed(6));
366         var alteredNumberString = alteredNumber.toString();
367
368         var from = {line: startPosition.line, ch: start};
369         var to = {line: startPosition.line, ch: end};
370
371         this.replaceRange(alteredNumberString, from, to);
372
373         if (updateSelection) {
374             var previousLength = to.ch - from.ch;
375             var newLength = alteredNumberString.length;
376
377             // Fix up the selection so it follows the increase or decrease in the replacement length.
378             if (previousLength != newLength) {
379                 if (selectionStart.line === from.line && selectionStart.ch > from.ch)
380                     selectionStart.ch += newLength - previousLength;
381
382                 if (selectionEnd.line === from.line && selectionEnd.ch > from.ch)
383                     selectionEnd.ch += newLength - previousLength;
384             }
385
386             this.setSelection(selectionStart, selectionEnd);
387         }
388
389         return true;
390     });
391
392     function alterNumber(amount, codeMirror)
393     {
394         function findNumberToken(position)
395         {
396             // CodeMirror includes the unit in the number token, so searching for
397             // number tokens is the best way to get both the number and unit.
398             var token = codeMirror.getTokenAt(position);
399             if (token && token.type && /\bnumber\b/.test(token.type))
400                 return token;
401             return null;
402         }
403
404         var position = codeMirror.getCursor("head");
405         var token = findNumberToken(position);
406
407         if (!token) {
408             // If the cursor is at the outside beginning of the token, the previous
409             // findNumberToken wont find it. So check the next column for a number too.
410             position.ch += 1;
411             token = findNumberToken(position);
412         }
413
414         if (!token)
415             return CodeMirror.Pass;
416
417         var foundNumber = codeMirror.alterNumberInRange(amount, {ch: token.start, line: position.line}, {ch: token.end, line: position.line}, true);
418         if (!foundNumber)
419             return CodeMirror.Pass;
420     }
421
422     CodeMirror.defineExtension("rectsForRange", function(range) {
423         var lineRects = [];
424
425         for (var line = range.start.line; line <= range.end.line; ++line) {
426             var lineContent = this.getLine(line);
427
428             var startChar = line === range.start.line ? range.start.ch : (lineContent.length - lineContent.trimLeft().length);
429             var endChar = line === range.end.line ? range.end.ch : lineContent.length;
430             var firstCharCoords = this.cursorCoords({ch: startChar, line: line});
431             var endCharCoords = this.cursorCoords({ch: endChar, line: line});
432
433             // Handle line wrapping.
434             if (firstCharCoords.bottom !== endCharCoords.bottom) {
435                 var maxY = -Number.MAX_VALUE;
436                 for (var ch = startChar; ch <= endChar; ++ch) {
437                     var coords = this.cursorCoords({ch: ch, line: line});
438                     if (coords.bottom > maxY) {
439                         if (ch > startChar) {
440                             var maxX = Math.ceil(this.cursorCoords({ch: ch - 1, line: line}).right);
441                             lineRects.push(new WebInspector.Rect(minX, minY, maxX - minX, maxY - minY));
442                         }
443                         var minX = Math.floor(coords.left);
444                         var minY = Math.floor(coords.top)
445                         maxY = Math.ceil(coords.bottom);
446                     }
447                 }
448                 maxX = Math.ceil(coords.right);
449                 lineRects.push(new WebInspector.Rect(minX, minY, maxX - minX, maxY - minY));
450             } else {
451                 var minX = Math.floor(firstCharCoords.left);
452                 var minY = Math.floor(firstCharCoords.top);
453                 var maxX = Math.ceil(endCharCoords.right);
454                 var maxY = Math.ceil(endCharCoords.bottom);
455                 lineRects.push(new WebInspector.Rect(minX, minY, maxX - minX, maxY - minY));
456             }
457         }
458         return lineRects;
459     });
460
461     CodeMirror.defineExtension("createColorMarkers", function(range, callback) {
462         var createdMarkers = [];
463
464         var start = range instanceof WebInspector.TextRange ? range.startLine : 0;
465         var end = range instanceof WebInspector.TextRange ? range.endLine + 1 : this.lineCount();
466
467         // Matches rgba(0, 0, 0, 0.5), rgb(0, 0, 0), hsl(), hsla(), #fff, #ffffff, white
468         const colorRegex = /((?:rgb|hsl)a?\([^)]+\)|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}|\b\w+\b(?![-.]))/g;
469
470         for (var lineNumber = start; lineNumber < end; ++lineNumber) {
471             var lineContent = this.getLine(lineNumber);
472             var match = colorRegex.exec(lineContent);
473             while (match) {
474
475                 // Act as a negative look-behind and disallow the color from being prefixing with certain characters.
476                 if (match.index > 0 && /[-.]/.test(lineContent[match.index - 1])) {
477                     match = colorRegex.exec(lineContent);
478                     continue;
479                 }
480
481                 var from = {line: lineNumber, ch: match.index};
482                 var to = {line: lineNumber, ch: match.index + match[0].length};
483
484                 var foundColorMarker = false;
485                 var markers = this.findMarksAt(to);
486                 for (var j = 0; j < markers.length; ++j) {
487                     if (WebInspector.TextMarker.textMarkerForCodeMirrorTextMarker(markers[j]).type === WebInspector.TextMarker.Type.Color) {
488                         foundColorMarker = true;
489                         break;
490                     }
491                 }
492
493                 if (foundColorMarker) {
494                     match = colorRegex.exec(lineContent);
495                     continue;
496                 }
497
498                 // We're not interested in text within a CSS selector.
499                 var tokenType = this.getTokenTypeAt(from);
500                 if (tokenType && (tokenType.indexOf("builtin") !== -1 || tokenType.indexOf("tag") !== -1)) {
501                     match = colorRegex.exec(lineContent);
502                     continue;
503                 }
504
505                 var colorString = match[0];
506                 var color = WebInspector.Color.fromString(colorString);
507                 if (!color) {
508                     match = colorRegex.exec(lineContent);
509                     continue;
510                 }
511
512                 var marker = this.markText(from, to);
513                 marker = new WebInspector.TextMarker(marker, WebInspector.TextMarker.Type.Color);
514
515                 createdMarkers.push(marker);
516
517                 if (callback)
518                     callback(marker, color, colorString);
519
520                 match = colorRegex.exec(lineContent);
521             }
522         }
523
524         return createdMarkers;
525     });
526
527     CodeMirror.defineExtension("createGradientMarkers", function(range, callback) {
528         var createdMarkers = [];
529
530         var start = range instanceof WebInspector.TextRange ? range.startLine : 0;
531         var end = range instanceof WebInspector.TextRange ? range.endLine + 1 : this.lineCount();
532
533         const gradientRegex = /(repeating-)?(linear|radial)-gradient\s*\(\s*/g;
534
535         for (var lineNumber = start; lineNumber < end; ++lineNumber) {
536             var lineContent = this.getLine(lineNumber);
537             var match = gradientRegex.exec(lineContent);
538             while (match) {
539                 var startLine = lineNumber;
540                 var startChar = match.index;
541                 var endChar = match.index + match[0].length;
542
543                 var openParentheses = 0;
544                 while (c = lineContent[endChar]) {
545                     if (c === "(")
546                         openParentheses++;
547                     if (c === ")")
548                         openParentheses--;
549
550                     if (openParentheses === -1) {
551                         endChar++;
552                         break;
553                     }
554
555                     endChar++;
556                     if (endChar >= lineContent.length) {
557                         lineNumber++;
558                         endChar = 0;
559                         lineContent = this.getLine(lineNumber);
560                         if (!lineContent)
561                             break;
562                     }
563                 }
564
565                 if (openParentheses !== -1) {
566                     match = gradientRegex.exec(lineContent);
567                     continue;
568                 }
569
570                 var from = {line: startLine, ch: startChar};
571                 var to = {line: lineNumber, ch: endChar};
572
573                 var gradientString = this.getRange(from, to);
574                 var gradient = WebInspector.Gradient.fromString(gradientString);
575                 if (!gradient) {
576                     match = gradientRegex.exec(lineContent);
577                     continue;
578                 }
579
580                 var marker = new WebInspector.TextMarker(this.markText(from, to), WebInspector.TextMarker.Type.Gradient);
581
582                 createdMarkers.push(marker);
583
584                 if (callback)
585                     callback(marker, gradient, gradientString);
586
587                 match = gradientRegex.exec(lineContent);
588             }
589         }
590
591         return createdMarkers;
592     });
593
594     function ignoreKey(codeMirror)
595     {
596         // Do nothing to ignore the key.
597     }
598
599     CodeMirror.keyMap["default"] = {
600         "Alt-Up": alterNumber.bind(null, 1),
601         "Ctrl-Alt-Up": alterNumber.bind(null, 0.1),
602         "Shift-Alt-Up": alterNumber.bind(null, 10),
603         "Alt-PageUp": alterNumber.bind(null, 10),
604         "Shift-Alt-PageUp": alterNumber.bind(null, 100),
605         "Alt-Down": alterNumber.bind(null, -1),
606         "Ctrl-Alt-Down": alterNumber.bind(null, -0.1),
607         "Shift-Alt-Down": alterNumber.bind(null, -10),
608         "Alt-PageDown": alterNumber.bind(null, -10),
609         "Shift-Alt-PageDown": alterNumber.bind(null, -100),
610         "Cmd-/": "toggleComment",
611         "Shift-Tab": ignoreKey,
612         fallthrough: "macDefault"
613     };
614
615     // Register some extra MIME-types for CodeMirror. These are in addition to the
616     // ones CodeMirror already registers, like text/html, text/javascript, etc.
617     const extraXMLTypes = ["text/xml", "text/xsl"];
618     extraXMLTypes.forEach(function(type) {
619         CodeMirror.defineMIME(type, "xml");
620     });
621
622     const extraHTMLTypes = ["application/xhtml+xml", "image/svg+xml"];
623     extraHTMLTypes.forEach(function(type) {
624         CodeMirror.defineMIME(type, "htmlmixed");
625     });
626
627     const extraJavaScriptTypes = ["text/ecmascript", "application/javascript", "application/ecmascript", "application/x-javascript",
628         "text/x-javascript", "text/javascript1.1", "text/javascript1.2", "text/javascript1.3", "text/jscript", "text/livescript"];
629     extraJavaScriptTypes.forEach(function(type) {
630         CodeMirror.defineMIME(type, "javascript");
631     });
632
633     const extraJSONTypes = ["application/x-json", "text/x-json"];
634     extraJSONTypes.forEach(function(type) {
635         CodeMirror.defineMIME(type, {name: "javascript", json: true});
636     });
637
638 })();
639
640 WebInspector.compareCodeMirrorPositions = function(a, b)
641 {
642     var lineCompare = a.line - b.line;
643     if (lineCompare !== 0)
644         return lineCompare;
645
646     var aColumn = "ch" in a ? a.ch : Number.MAX_VALUE;
647     var bColumn = "ch" in b ? b.ch : Number.MAX_VALUE;
648     return aColumn - bColumn;
649 };