- add sources.
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / net_internals / log_view_painter.js
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 // TODO(eroman): put these methods into a namespace.
6
7 var printLogEntriesAsText;
8 var createLogEntryTablePrinter;
9 var proxySettingsToString;
10 var stripCookiesAndLoginInfo;
11
12 // Start of anonymous namespace.
13 (function() {
14 'use strict';
15
16 function canCollapseBeginWithEnd(beginEntry) {
17   return beginEntry &&
18          beginEntry.isBegin() &&
19          beginEntry.end &&
20          beginEntry.end.index == beginEntry.index + 1 &&
21          (!beginEntry.orig.params || !beginEntry.end.orig.params);
22 }
23
24 /**
25  * Adds a child pre element to the end of |parent|, and writes the
26  * formatted contents of |logEntries| to it.
27  */
28 printLogEntriesAsText = function(logEntries, parent, privacyStripping,
29                                  logCreationTime) {
30   var tablePrinter = createLogEntryTablePrinter(logEntries, privacyStripping,
31                                                 logCreationTime);
32
33   // Format the table for fixed-width text.
34   tablePrinter.toText(0, parent);
35 }
36 /**
37  * Creates a TablePrinter for use by the above two functions.
38  */
39 createLogEntryTablePrinter = function(logEntries, privacyStripping,
40                                       logCreationTime) {
41   var entries = LogGroupEntry.createArrayFrom(logEntries);
42   var tablePrinter = new TablePrinter();
43   var parameterOutputter = new ParameterOutputter(tablePrinter);
44
45   if (entries.length == 0)
46     return tablePrinter;
47
48   var startTime = timeutil.convertTimeTicksToTime(entries[0].orig.time);
49
50   for (var i = 0; i < entries.length; ++i) {
51     var entry = entries[i];
52
53     // Avoid printing the END for a BEGIN that was immediately before, unless
54     // both have extra parameters.
55     if (!entry.isEnd() || !canCollapseBeginWithEnd(entry.begin)) {
56       var entryTime = timeutil.convertTimeTicksToTime(entry.orig.time);
57       addRowWithTime(tablePrinter, entryTime, startTime);
58
59       for (var j = entry.getDepth(); j > 0; --j)
60         tablePrinter.addCell('  ');
61
62       var eventText = getTextForEvent(entry);
63       // Get the elapsed time, and append it to the event text.
64       if (entry.isBegin()) {
65         var dt = '?';
66         // Definite time.
67         if (entry.end) {
68           dt = entry.end.orig.time - entry.orig.time;
69         } else if (logCreationTime != undefined) {
70           dt = (logCreationTime - entryTime) + '+';
71         }
72         eventText += '  [dt=' + dt + ']';
73       }
74
75       var mainCell = tablePrinter.addCell(eventText);
76       mainCell.allowOverflow = true;
77     }
78
79     // Output the extra parameters.
80     if (typeof entry.orig.params == 'object') {
81       // Those 5 skipped cells are: two for "t=", and three for "st=".
82       tablePrinter.setNewRowCellIndent(5 + entry.getDepth());
83       writeParameters(entry.orig, privacyStripping, parameterOutputter);
84
85       tablePrinter.setNewRowCellIndent(0);
86     }
87   }
88
89   // If viewing a saved log file, add row with just the time the log was
90   // created, if the event never completed.
91   var lastEntry = entries[entries.length - 1];
92   // If the last entry has a non-zero depth or is a begin event, the source is
93   // still active.
94   var isSourceActive = lastEntry.getDepth() != 0 || lastEntry.isBegin();
95   if (logCreationTime != undefined && isSourceActive)
96     addRowWithTime(tablePrinter, logCreationTime, startTime);
97
98   return tablePrinter;
99 }
100
101 /**
102  * Adds a new row to the given TablePrinter, and adds five cells containing
103  * information about the time an event occured.
104  * Format is '[t=<UTC time in ms>] [st=<ms since the source started>]'.
105  * @param {TablePrinter} tablePrinter The table printer to add the cells to.
106  * @param {number} eventTime The time the event occured, as a UTC time in
107  *     milliseconds.
108  * @param {number} startTime The time the first event for the source occured,
109  *     as a UTC time in milliseconds.
110  */
111 function addRowWithTime(tablePrinter, eventTime, startTime) {
112   tablePrinter.addRow();
113   tablePrinter.addCell('t=');
114   var tCell = tablePrinter.addCell(eventTime);
115   tCell.alignRight = true;
116   tablePrinter.addCell(' [st=');
117   var stCell = tablePrinter.addCell(eventTime - startTime);
118   stCell.alignRight = true;
119   tablePrinter.addCell('] ');
120 }
121
122 /**
123  * |hexString| must be a string of hexadecimal characters with no whitespace,
124  * whose length is a multiple of two.  Writes multiple lines to |out| with
125  * the hexadecimal characters from |hexString| on the left, in groups of
126  * two, and their corresponding ASCII characters on the right.
127  *
128  * |asciiCharsPerLine| specifies how many ASCII characters will be put on each
129  * line of the output string.
130  */
131 function writeHexString(hexString, asciiCharsPerLine, out) {
132   // Number of transferred bytes in a line of output.  Length of a
133   // line is roughly 4 times larger.
134   var hexCharsPerLine = 2 * asciiCharsPerLine;
135   for (var i = 0; i < hexString.length; i += hexCharsPerLine) {
136     var hexLine = '';
137     var asciiLine = '';
138     for (var j = i; j < i + hexCharsPerLine && j < hexString.length; j += 2) {
139       var hex = hexString.substr(j, 2);
140       hexLine += hex + ' ';
141       var charCode = parseInt(hex, 16);
142       // For ASCII codes 32 though 126, display the corresponding
143       // characters.  Use a space for nulls, and a period for
144       // everything else.
145       if (charCode >= 0x20 && charCode <= 0x7E) {
146         asciiLine += String.fromCharCode(charCode);
147       } else if (charCode == 0x00) {
148         asciiLine += ' ';
149       } else {
150         asciiLine += '.';
151       }
152     }
153
154     // Make the ASCII text for the last line of output align with the previous
155     // lines.
156     hexLine += makeRepeatedString(' ', 3 * asciiCharsPerLine - hexLine.length);
157     out.writeLine('   ' + hexLine + '  ' + asciiLine);
158   }
159 }
160
161 /**
162  * Wrapper around a TablePrinter to simplify outputting lines of text for event
163  * parameters.
164  */
165 var ParameterOutputter = (function() {
166   /**
167    * @constructor
168    */
169   function ParameterOutputter(tablePrinter) {
170     this.tablePrinter_ = tablePrinter;
171   }
172
173   ParameterOutputter.prototype = {
174     /**
175      * Outputs a single line.
176      */
177     writeLine: function(line) {
178       this.tablePrinter_.addRow();
179       var cell = this.tablePrinter_.addCell(line);
180       cell.allowOverflow = true;
181       return cell;
182     },
183
184     /**
185      * Outputs a key=value line which looks like:
186      *
187      *   --> key = value
188      */
189     writeArrowKeyValue: function(key, value, link) {
190       var cell = this.writeLine(kArrow + key + ' = ' + value);
191       cell.link = link;
192     },
193
194     /**
195      * Outputs a key= line which looks like:
196      *
197      *   --> key =
198      */
199     writeArrowKey: function(key) {
200       this.writeLine(kArrow + key + ' =');
201     },
202
203     /**
204      * Outputs multiple lines, each indented by numSpaces.
205      * For instance if numSpaces=8 it might look like this:
206      *
207      *         line 1
208      *         line 2
209      *         line 3
210      */
211     writeSpaceIndentedLines: function(numSpaces, lines) {
212       var prefix = makeRepeatedString(' ', numSpaces);
213       for (var i = 0; i < lines.length; ++i)
214         this.writeLine(prefix + lines[i]);
215     },
216
217     /**
218      * Outputs multiple lines such that the first line has
219      * an arrow pointing at it, and subsequent lines
220      * align with the first one. For example:
221      *
222      *   --> line 1
223      *       line 2
224      *       line 3
225      */
226     writeArrowIndentedLines: function(lines) {
227       if (lines.length == 0)
228         return;
229
230       this.writeLine(kArrow + lines[0]);
231
232       for (var i = 1; i < lines.length; ++i)
233         this.writeLine(kArrowIndentation + lines[i]);
234     }
235   };
236
237   var kArrow = ' --> ';
238   var kArrowIndentation = '     ';
239
240   return ParameterOutputter;
241 })();  // end of ParameterOutputter
242
243 /**
244  * Formats the parameters for |entry| and writes them to |out|.
245  * Certain event types have custom pretty printers. Everything else will
246  * default to a JSON-like format.
247  */
248 function writeParameters(entry, privacyStripping, out) {
249   if (privacyStripping) {
250     // If privacy stripping is enabled, remove data as needed.
251     entry = stripCookiesAndLoginInfo(entry);
252   } else {
253     // If headers are in an object, convert them to an array for better display.
254     entry = reformatHeaders(entry);
255   }
256
257   // Use any parameter writer available for this event type.
258   var paramsWriter = getParamaterWriterForEventType(entry.type);
259   var consumedParams = {};
260   if (paramsWriter)
261     paramsWriter(entry, out, consumedParams);
262
263   // Write any un-consumed parameters.
264   for (var k in entry.params) {
265     if (consumedParams[k])
266       continue;
267     defaultWriteParameter(k, entry.params[k], out);
268   }
269 }
270
271 /**
272  * Finds a writer to format the parameters for events of type |eventType|.
273  *
274  * @return {function} The returned function "writer" can be invoked
275  *                    as |writer(entry, writer, consumedParams)|. It will
276  *                    output the parameters of |entry| to |out|, and fill
277  *                    |consumedParams| with the keys of the parameters
278  *                    consumed. If no writer is available for |eventType| then
279  *                    returns null.
280  */
281 function getParamaterWriterForEventType(eventType) {
282   switch (eventType) {
283     case EventType.HTTP_TRANSACTION_SEND_REQUEST_HEADERS:
284     case EventType.HTTP_TRANSACTION_SEND_TUNNEL_HEADERS:
285       return writeParamsForRequestHeaders;
286
287     case EventType.PROXY_CONFIG_CHANGED:
288       return writeParamsForProxyConfigChanged;
289
290     case EventType.CERT_VERIFIER_JOB:
291     case EventType.SSL_CERTIFICATES_RECEIVED:
292       return writeParamsForCertificates;
293
294     case EventType.SSL_VERSION_FALLBACK:
295       return writeParamsForSSLVersionFallback;
296   }
297   return null;
298 }
299
300 /**
301  * Default parameter writer that outputs a visualization of field named |key|
302  * with value |value| to |out|.
303  */
304 function defaultWriteParameter(key, value, out) {
305   if (key == 'headers' && value instanceof Array) {
306     out.writeArrowIndentedLines(value);
307     return;
308   }
309
310   // For transferred bytes, display the bytes in hex and ASCII.
311   if (key == 'hex_encoded_bytes' && typeof value == 'string') {
312     out.writeArrowKey(key);
313     writeHexString(value, 20, out);
314     return;
315   }
316
317   // Handle source_dependency entries - add link and map source type to
318   // string.
319   if (key == 'source_dependency' && typeof value == 'object') {
320     var link = '#events&s=' + value.id;
321     var valueStr = value.id + ' (' + EventSourceTypeNames[value.type] + ')';
322     out.writeArrowKeyValue(key, valueStr, link);
323     return;
324   }
325
326   if (key == 'net_error' && typeof value == 'number') {
327     var valueStr = value + ' (' + netErrorToString(value) + ')';
328     out.writeArrowKeyValue(key, valueStr);
329     return;
330   }
331
332   if (key == 'quic_error' && typeof value == 'number') {
333     var valueStr = value + ' (' + quicErrorToString(value) + ')';
334     out.writeArrowKeyValue(key, valueStr);
335     return;
336   }
337
338   if (key == 'quic_crypto_handshake_message' && typeof value == 'string') {
339     var lines = value.split('\n');
340     out.writeArrowIndentedLines(lines);
341     return;
342   }
343
344   if (key == 'quic_rst_stream_error' && typeof value == 'number') {
345     var valueStr = value + ' (' + quicRstStreamErrorToString(value) + ')';
346     out.writeArrowKeyValue(key, valueStr);
347     return;
348   }
349
350   if (key == 'load_flags' && typeof value == 'number') {
351     var valueStr = value + ' (' + getLoadFlagSymbolicString(value) + ')';
352     out.writeArrowKeyValue(key, valueStr);
353     return;
354   }
355
356   if (key == 'load_state' && typeof value == 'number') {
357     var valueStr = value + ' (' + getKeyWithValue(LoadState, value) + ')';
358     out.writeArrowKeyValue(key, valueStr);
359     return;
360   }
361
362   // Otherwise just default to JSON formatting of the value.
363   out.writeArrowKeyValue(key, JSON.stringify(value));
364 }
365
366 /**
367  * Returns the set of LoadFlags that make up the integer |loadFlag|.
368  * For example: getLoadFlagSymbolicString(
369  */
370 function getLoadFlagSymbolicString(loadFlag) {
371   // Load flag of 0 means "NORMAL". Special case this, since and-ing with
372   // 0 is always going to be false.
373   if (loadFlag == 0)
374     return getKeyWithValue(LoadFlag, loadFlag);
375
376   var matchingLoadFlagNames = [];
377
378   for (var k in LoadFlag) {
379     if (loadFlag & LoadFlag[k])
380       matchingLoadFlagNames.push(k);
381   }
382
383   return matchingLoadFlagNames.join(' | ');
384 }
385
386 /**
387  * Converts an SSL version number to a textual representation.
388  * For instance, SSLVersionNumberToName(0x0301) returns 'TLS 1.0'.
389  */
390 function SSLVersionNumberToName(version) {
391   if ((version & 0xFFFF) != version) {
392     // If the version number is more than 2 bytes long something is wrong.
393     // Print it as hex.
394     return 'SSL 0x' + version.toString(16);
395   }
396
397   // See if it is a known TLS name.
398   var kTLSNames = {
399     0x0301: 'TLS 1.0',
400     0x0302: 'TLS 1.1',
401     0x0303: 'TLS 1.2'
402   };
403   var name = kTLSNames[version];
404   if (name)
405     return name;
406
407   // Otherwise label it as an SSL version.
408   var major = (version & 0xFF00) >> 8;
409   var minor = version & 0x00FF;
410
411   return 'SSL ' + major + '.' + minor;
412 }
413
414 /**
415  * TODO(eroman): get rid of this, as it is only used by 1 callsite.
416  *
417  * Indent |lines| by |start|.
418  *
419  * For example, if |start| = ' -> ' and |lines| = ['line1', 'line2', 'line3']
420  * the output will be:
421  *
422  *   " -> line1\n" +
423  *   "    line2\n" +
424  *   "    line3"
425  */
426 function indentLines(start, lines) {
427   return start + lines.join('\n' + makeRepeatedString(' ', start.length));
428 }
429
430 /**
431  * If entry.param.headers exists and is an object other than an array, converts
432  * it into an array and returns a new entry.  Otherwise, just returns the
433  * original entry.
434  */
435 function reformatHeaders(entry) {
436   // If there are no headers, or it is not an object other than an array,
437   // return |entry| without modification.
438   if (!entry.params || entry.params.headers === undefined ||
439       typeof entry.params.headers != 'object' ||
440       entry.params.headers instanceof Array) {
441     return entry;
442   }
443
444   // Duplicate the top level object, and |entry.params|, so the original object
445   // will not be modified.
446   entry = shallowCloneObject(entry);
447   entry.params = shallowCloneObject(entry.params);
448
449   // Convert headers to an array.
450   var headers = [];
451   for (var key in entry.params.headers)
452     headers.push(key + ': ' + entry.params.headers[key]);
453   entry.params.headers = headers;
454
455   return entry;
456 }
457
458 /**
459  * Removes a cookie or unencrypted login information from a single HTTP header
460  * line, if present, and returns the modified line.  Otherwise, just returns
461  * the original line.
462  */
463 function stripCookieOrLoginInfo(line) {
464   var patterns = [
465       // Cookie patterns
466       /^set-cookie: /i,
467       /^set-cookie2: /i,
468       /^cookie: /i,
469
470       // Unencrypted authentication patterns
471       /^authorization: \S*\s*/i,
472       /^proxy-authorization: \S*\s*/i];
473
474   // Prefix will hold the first part of the string that contains no private
475   // information.  If null, no part of the string contains private information.
476   var prefix = null;
477   for (var i = 0; i < patterns.length; i++) {
478     var match = patterns[i].exec(line);
479     if (match != null) {
480       prefix = match[0];
481       break;
482     }
483   }
484
485   // Look for authentication information from data received from the server in
486   // multi-round Negotiate authentication.
487   if (prefix === null) {
488     var challengePatterns = [
489         /^www-authenticate: (\S*)\s*/i,
490         /^proxy-authenticate: (\S*)\s*/i];
491     for (var i = 0; i < challengePatterns.length; i++) {
492       var match = challengePatterns[i].exec(line);
493       if (!match)
494         continue;
495
496       // If there's no data after the scheme name, do nothing.
497       if (match[0].length == line.length)
498         break;
499
500       // Ignore lines with commas, as they may contain lists of schemes, and
501       // the information we want to hide is Base64 encoded, so has no commas.
502       if (line.indexOf(',') >= 0)
503         break;
504
505       // Ignore Basic and Digest authentication challenges, as they contain
506       // public information.
507       if (/^basic$/i.test(match[1]) || /^digest$/i.test(match[1]))
508         break;
509
510       prefix = match[0];
511       break;
512     }
513   }
514
515   if (prefix) {
516     var suffix = line.slice(prefix.length);
517     // If private information has already been removed, keep the line as-is.
518     // This is often the case when viewing a loaded log.
519     // TODO(mmenke):  Remove '[value was stripped]' check once M24 hits stable.
520     if (suffix.search(/^\[[0-9]+ bytes were stripped\]$/) == -1 &&
521         suffix != '[value was stripped]') {
522       return prefix + '[' + suffix.length + ' bytes were stripped]';
523     }
524   }
525
526   return line;
527 }
528
529 /**
530  * If |entry| has headers, returns a copy of |entry| with all cookie and
531  * unencrypted login text removed.  Otherwise, returns original |entry| object.
532  * This is needed so that JSON log dumps can be made without affecting the
533  * source data.  Converts headers stored in objects to arrays.
534  */
535 stripCookiesAndLoginInfo = function(entry) {
536   if (!entry.params || entry.params.headers === undefined ||
537       !(entry.params.headers instanceof Object)) {
538     return entry;
539   }
540
541   // Make sure entry's headers are in an array.
542   entry = reformatHeaders(entry);
543
544   // Duplicate the top level object, and |entry.params|.  All other fields are
545   // just pointers to the original values, as they won't be modified, other than
546   // |entry.params.headers|.
547   entry = shallowCloneObject(entry);
548   entry.params = shallowCloneObject(entry.params);
549
550   entry.params.headers = entry.params.headers.map(stripCookieOrLoginInfo);
551   return entry;
552 }
553
554 /**
555  * Outputs the request header parameters of |entry| to |out|.
556  */
557 function writeParamsForRequestHeaders(entry, out, consumedParams) {
558   var params = entry.params;
559
560   if (!(typeof params.line == 'string') || !(params.headers instanceof Array)) {
561     // Unrecognized params.
562     return;
563   }
564
565   // Strip the trailing CRLF that params.line contains.
566   var lineWithoutCRLF = params.line.replace(/\r\n$/g, '');
567   out.writeArrowIndentedLines([lineWithoutCRLF].concat(params.headers));
568
569   consumedParams.line = true;
570   consumedParams.headers = true;
571 }
572
573 /**
574  * Outputs the certificate parameters of |entry| to |out|.
575  */
576 function writeParamsForCertificates(entry, out, consumedParams) {
577   if (!(entry.params.certificates instanceof Array)) {
578     // Unrecognized params.
579     return;
580   }
581
582   var certs = entry.params.certificates.reduce(function(previous, current) {
583     return previous.concat(current.split('\n'));
584   }, new Array());
585
586   out.writeArrowKey('certificates');
587   out.writeSpaceIndentedLines(8, certs);
588
589   consumedParams.certificates = true;
590 }
591
592 /**
593  * Outputs the SSL version fallback parameters of |entry| to |out|.
594  */
595 function writeParamsForSSLVersionFallback(entry, out, consumedParams) {
596   var params = entry.params;
597
598   if (typeof params.version_before != 'number' ||
599       typeof params.version_after != 'number') {
600     // Unrecognized params.
601     return;
602   }
603
604   var line = SSLVersionNumberToName(params.version_before) +
605              ' ==> ' +
606              SSLVersionNumberToName(params.version_after);
607   out.writeArrowIndentedLines([line]);
608
609   consumedParams.version_before = true;
610   consumedParams.version_after = true;
611 }
612
613 function writeParamsForProxyConfigChanged(entry, out, consumedParams) {
614   var params = entry.params;
615
616   if (typeof params.new_config != 'object') {
617     // Unrecognized params.
618     return;
619   }
620
621   if (typeof params.old_config == 'object') {
622     var oldConfigString = proxySettingsToString(params.old_config);
623     // The previous configuration may not be present in the case of
624     // the initial proxy settings fetch.
625     out.writeArrowKey('old_config');
626
627     out.writeSpaceIndentedLines(8, oldConfigString.split('\n'));
628
629     consumedParams.old_config = true;
630   }
631
632   var newConfigString = proxySettingsToString(params.new_config);
633   out.writeArrowKey('new_config');
634   out.writeSpaceIndentedLines(8, newConfigString.split('\n'));
635
636   consumedParams.new_config = true;
637 }
638
639 function getTextForEvent(entry) {
640   var text = '';
641
642   if (entry.isBegin() && canCollapseBeginWithEnd(entry)) {
643     // Don't prefix with '+' if we are going to collapse the END event.
644     text = ' ';
645   } else if (entry.isBegin()) {
646     text = '+' + text;
647   } else if (entry.isEnd()) {
648     text = '-' + text;
649   } else {
650     text = ' ';
651   }
652
653   text += EventTypeNames[entry.orig.type];
654   return text;
655 }
656
657 proxySettingsToString = function(config) {
658   if (!config)
659     return '';
660
661   // TODO(eroman): if |config| has unexpected properties, print it as JSON
662   //               rather than hide them.
663
664   function getProxyListString(proxies) {
665     // Older versions of Chrome would set these values as strings, whereas newer
666     // logs use arrays.
667     // TODO(eroman): This behavior changed in M27. Support for older logs can
668     //               safely be removed circa M29.
669     if (Array.isArray(proxies)) {
670       var listString = proxies.join(', ');
671       if (proxies.length > 1)
672         return '[' + listString + ']';
673       return listString;
674     }
675     return proxies;
676   }
677
678   // The proxy settings specify up to three major fallback choices
679   // (auto-detect, custom pac url, or manual settings).
680   // We enumerate these to a list so we can later number them.
681   var modes = [];
682
683   // Output any automatic settings.
684   if (config.auto_detect)
685     modes.push(['Auto-detect']);
686   if (config.pac_url)
687     modes.push(['PAC script: ' + config.pac_url]);
688
689   // Output any manual settings.
690   if (config.single_proxy || config.proxy_per_scheme) {
691     var lines = [];
692
693     if (config.single_proxy) {
694       lines.push('Proxy server: ' + getProxyListString(config.single_proxy));
695     } else if (config.proxy_per_scheme) {
696       for (var urlScheme in config.proxy_per_scheme) {
697         if (urlScheme != 'fallback') {
698           lines.push('Proxy server for ' + urlScheme.toUpperCase() + ': ' +
699                      getProxyListString(config.proxy_per_scheme[urlScheme]));
700         }
701       }
702       if (config.proxy_per_scheme.fallback) {
703         lines.push('Proxy server for everything else: ' +
704                    getProxyListString(config.proxy_per_scheme.fallback));
705       }
706     }
707
708     // Output any proxy bypass rules.
709     if (config.bypass_list) {
710       if (config.reverse_bypass) {
711         lines.push('Reversed bypass list: ');
712       } else {
713         lines.push('Bypass list: ');
714       }
715
716       for (var i = 0; i < config.bypass_list.length; ++i)
717         lines.push('  ' + config.bypass_list[i]);
718     }
719
720     modes.push(lines);
721   }
722
723   var result = [];
724   if (modes.length < 1) {
725     // If we didn't find any proxy settings modes, we are using DIRECT.
726     result.push('Use DIRECT connections.');
727   } else if (modes.length == 1) {
728     // If there was just one mode, don't bother numbering it.
729     result.push(modes[0].join('\n'));
730   } else {
731     // Otherwise concatenate all of the modes into a numbered list
732     // (which correspond with the fallback order).
733     for (var i = 0; i < modes.length; ++i)
734       result.push(indentLines('(' + (i + 1) + ') ', modes[i]));
735   }
736
737   if (config.source != undefined && config.source != 'UNKNOWN')
738     result.push('Source: ' + config.source);
739
740   return result.join('\n');
741 };
742
743 // End of anonymous namespace.
744 })();