| // Copyright 2013 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import * as Platform from '../../core/platform/platform.js'; | |
| import {ContentData, type ContentDataOrError} from './ContentData.js'; | |
| import {SearchMatch} from './ContentProvider.js'; | |
| import type {Text} from './Text.js'; | |
| const KEY_VALUE_FILTER_REGEXP = /(?:^|\s)(\-)?([\w\-]+):([^\s]+)/; | |
| const REGEXP_FILTER_REGEXP = /(?:^|\s)(\-)?\/((?:[^\/\\]|\\.)+)\//; | |
| const TEXT_FILTER_REGEXP = /(?:^|\s)(\-)?([^\s]+)/; | |
| const SPACE_CHAR_REGEXP = /\s/; | |
| export const Utils = { | |
| isSpaceChar: function(char: string): boolean { | |
| return SPACE_CHAR_REGEXP.test(char); | |
| }, | |
| lineIndent: function(line: string): string { | |
| let indentation = 0; | |
| while (indentation < line.length && Utils.isSpaceChar(line.charAt(indentation))) { | |
| ++indentation; | |
| } | |
| return line.substr(0, indentation); | |
| }, | |
| splitStringByRegexes(text: string, regexes: RegExp[]): Array<{ | |
| value: string, | |
| position: number, | |
| regexIndex: number, | |
| captureGroups: Array<string|undefined>, | |
| }> { | |
| const matches: Array<{ | |
| value: string, | |
| position: number, | |
| regexIndex: number, | |
| captureGroups: Array<string|undefined>, | |
| }> = []; | |
| const globalRegexes: RegExp[] = []; | |
| for (let i = 0; i < regexes.length; i++) { | |
| const regex = regexes[i]; | |
| if (!regex.global) { | |
| globalRegexes.push(new RegExp(regex.source, regex.flags ? regex.flags + 'g' : 'g')); | |
| } else { | |
| globalRegexes.push(regex); | |
| } | |
| } | |
| doSplit(text, 0, 0); | |
| return matches; | |
| function doSplit(text: string, regexIndex: number, startIndex: number): void { | |
| if (regexIndex >= globalRegexes.length) { | |
| // Set regexIndex as -1 if text did not match with any regular expression | |
| matches.push({value: text, position: startIndex, regexIndex: -1, captureGroups: []}); | |
| return; | |
| } | |
| const regex = globalRegexes[regexIndex]; | |
| let currentIndex = 0; | |
| let result; | |
| regex.lastIndex = 0; | |
| while ((result = regex.exec(text)) !== null) { | |
| const stringBeforeMatch = text.substring(currentIndex, result.index); | |
| if (stringBeforeMatch) { | |
| doSplit(stringBeforeMatch, regexIndex + 1, startIndex + currentIndex); | |
| } | |
| const match = result[0]; | |
| matches.push({ | |
| value: match, | |
| position: startIndex + result.index, | |
| regexIndex, | |
| captureGroups: result.slice(1), | |
| }); | |
| currentIndex = result.index + match.length; | |
| } | |
| const stringAfterMatches = text.substring(currentIndex); | |
| if (stringAfterMatches) { | |
| doSplit(stringAfterMatches, regexIndex + 1, startIndex + currentIndex); | |
| } | |
| } | |
| }, | |
| }; | |
| export class FilterParser { | |
| private readonly keys: string[]; | |
| constructor(keys: string[]) { | |
| this.keys = keys; | |
| } | |
| static cloneFilter(filter: ParsedFilter): ParsedFilter { | |
| return {key: filter.key, text: filter.text, regex: filter.regex, negative: filter.negative}; | |
| } | |
| parse(query: string): ParsedFilter[] { | |
| const splitFilters = | |
| Utils.splitStringByRegexes(query, [KEY_VALUE_FILTER_REGEXP, REGEXP_FILTER_REGEXP, TEXT_FILTER_REGEXP]); | |
| const parsedFilters: ParsedFilter[] = []; | |
| for (const {regexIndex, captureGroups} of splitFilters) { | |
| if (regexIndex === -1) { | |
| continue; | |
| } | |
| if (regexIndex === 0) { | |
| const startsWithMinus = captureGroups[0]; | |
| const parsedKey = captureGroups[1]; | |
| const parsedValue = captureGroups[2]; | |
| if (this.keys.indexOf((parsedKey as string)) !== -1) { | |
| parsedFilters.push({ | |
| key: parsedKey, | |
| text: parsedValue, | |
| negative: Boolean(startsWithMinus), | |
| }); | |
| } else { | |
| parsedFilters.push({ | |
| text: `${parsedKey}:${parsedValue}`, | |
| negative: Boolean(startsWithMinus), | |
| }); | |
| } | |
| } else if (regexIndex === 1) { | |
| const startsWithMinus = captureGroups[0]; | |
| const parsedRegex = captureGroups[1]; | |
| try { | |
| parsedFilters.push({ | |
| regex: new RegExp((parsedRegex as string), 'im'), | |
| negative: Boolean(startsWithMinus), | |
| }); | |
| } catch { | |
| parsedFilters.push({ | |
| text: `/${parsedRegex}/`, | |
| negative: Boolean(startsWithMinus), | |
| }); | |
| } | |
| } else if (regexIndex === 2) { | |
| const startsWithMinus = captureGroups[0]; | |
| const parsedText = captureGroups[1]; | |
| parsedFilters.push({ | |
| text: parsedText, | |
| negative: Boolean(startsWithMinus), | |
| }); | |
| } | |
| } | |
| return parsedFilters; | |
| } | |
| } | |
| export class BalancedJSONTokenizer { | |
| private readonly callback: (arg0: string) => void; | |
| private index: number; | |
| private balance: number; | |
| private buffer: string; | |
| private findMultiple: boolean; | |
| private closingDoubleQuoteRegex: RegExp; | |
| private lastBalancedIndex?: number; | |
| constructor(callback: (arg0: string) => void, findMultiple?: boolean) { | |
| this.callback = callback; | |
| this.index = 0; | |
| this.balance = 0; | |
| this.buffer = ''; | |
| this.findMultiple = findMultiple || false; | |
| this.closingDoubleQuoteRegex = /[^\\](?:\\\\)*"/g; | |
| } | |
| write(chunk: string): boolean { | |
| this.buffer += chunk; | |
| const lastIndex = this.buffer.length; | |
| const buffer = this.buffer; | |
| let index; | |
| for (index = this.index; index < lastIndex; ++index) { | |
| const character = buffer[index]; | |
| if (character === '"') { | |
| this.closingDoubleQuoteRegex.lastIndex = index; | |
| if (!this.closingDoubleQuoteRegex.test(buffer)) { | |
| break; | |
| } | |
| index = this.closingDoubleQuoteRegex.lastIndex - 1; | |
| } else if (character === '{') { | |
| ++this.balance; | |
| } else if (character === '}') { | |
| --this.balance; | |
| if (this.balance < 0) { | |
| this.reportBalanced(); | |
| return false; | |
| } | |
| if (!this.balance) { | |
| this.lastBalancedIndex = index + 1; | |
| if (!this.findMultiple) { | |
| break; | |
| } | |
| } | |
| } else if (character === ']' && !this.balance) { | |
| this.reportBalanced(); | |
| return false; | |
| } | |
| } | |
| this.index = index; | |
| this.reportBalanced(); | |
| return true; | |
| } | |
| private reportBalanced(): void { | |
| if (!this.lastBalancedIndex) { | |
| return; | |
| } | |
| this.callback(this.buffer.slice(0, this.lastBalancedIndex)); | |
| this.buffer = this.buffer.slice(this.lastBalancedIndex); | |
| this.index -= this.lastBalancedIndex; | |
| this.lastBalancedIndex = 0; | |
| } | |
| remainder(): string { | |
| return this.buffer; | |
| } | |
| } | |
| /** | |
| * Detects the indentation used by a given text document, based on the _Comparing | |
| * lines_ approach suggested by Heather Arthur (and also found in Firefox DevTools). | |
| * | |
| * This implementation differs from the original proposal in that tab indentation | |
| * isn't detected by checking if at least 50% of the lines start with a tab, but | |
| * instead by comparing the number of lines that start with a tab to the frequency | |
| * of the other indentation patterns. This way we also detect small snippets with | |
| * long leading comments correctly, when tab indentation is used for the snippets | |
| * of code. | |
| * | |
| * @param lines The input document lines. | |
| * @returns The indentation detected for the lines as string or `null` if it's inconclusive. | |
| * @see https://heathermoor.medium.com/detecting-code-indentation-eff3ed0fb56b | |
| */ | |
| export const detectIndentation = function(lines: Iterable<string>): string|null { | |
| const frequencies: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0]; | |
| let tabs = 0, previous = 0; | |
| for (const line of lines) { | |
| let current = 0; | |
| if (line.length !== 0) { | |
| let char = line.charAt(0); | |
| if (char === '\t') { | |
| tabs++; | |
| continue; | |
| } | |
| while (char === ' ') { | |
| char = line.charAt(++current); | |
| } | |
| } | |
| if (current === line.length) { | |
| // Don't consider empty lines. | |
| previous = 0; | |
| continue; | |
| } | |
| const delta = Math.abs(current - previous); | |
| if (delta < frequencies.length) { | |
| // Don't consider deltas above 8 characters. | |
| frequencies[delta] = frequencies[delta] + 1; | |
| } | |
| previous = current; | |
| } | |
| // Find most frequent non-zero width difference between adjacent lines. | |
| let mostFrequentDelta = 0, highestFrequency = 0; | |
| for (let delta = 1; delta < frequencies.length; ++delta) { | |
| const frequency = frequencies[delta]; | |
| if (frequency > highestFrequency) { | |
| highestFrequency = frequency; | |
| mostFrequentDelta = delta; | |
| } | |
| } | |
| if (tabs > mostFrequentDelta) { | |
| // If more lines start with tabs than any other indentation, | |
| // we assume that the document was written with tab indentation | |
| // in mind. This differs from the original algorithm. | |
| return '\t'; | |
| } | |
| if (!mostFrequentDelta) { | |
| return null; | |
| } | |
| return ' '.repeat(mostFrequentDelta); | |
| }; | |
| /** | |
| * Heuristic to check whether a given text was likely minified. Intended to | |
| * be used for HTML, CSS, and JavaScript inputs. | |
| * | |
| * A text is considered to be the result of minification if the average | |
| * line length for the whole text is 80 characters or more. | |
| * | |
| * @param text The input text to check. | |
| * @returns `true` if the heuristic considers `text` to be minified. | |
| */ | |
| export const isMinified = function(text: string): boolean { | |
| let lineCount = 0; | |
| for (let lastIndex = 0; lastIndex < text.length; ++lineCount) { | |
| let eolIndex = text.indexOf('\n', lastIndex); | |
| if (eolIndex < 0) { | |
| eolIndex = text.length; | |
| } | |
| lastIndex = eolIndex + 1; | |
| } | |
| return (text.length - lineCount) / lineCount >= 80; | |
| }; | |
| /** | |
| * Small wrapper around {@link performSearchInContent} to reduce boilerplate when searching | |
| * in {@link ContentDataOrError}. | |
| * | |
| * @returns empty search matches if `contentData` is an error or not text content. | |
| */ | |
| export const performSearchInContentData = function(contentData: ContentDataOrError, query: string, | |
| caseSensitive: boolean, isRegex: boolean): SearchMatch[] { | |
| if (ContentData.isError(contentData) || !contentData.isTextContent) { | |
| return []; | |
| } | |
| return performSearchInContent(contentData.textObj, query, caseSensitive, isRegex); | |
| }; | |
| /** | |
| * @returns One {@link SearchMatch} per match. Multiple matches on the same line each | |
| * result in their own `SearchMatchExact` instance. | |
| */ | |
| export const performSearchInContent = function(text: Text, query: string, caseSensitive: boolean, | |
| isRegex: boolean): SearchMatch[] { | |
| const regex = Platform.StringUtilities.createSearchRegex(query, caseSensitive, isRegex); | |
| const result = []; | |
| for (let i = 0; i < text.lineCount(); ++i) { | |
| const lineContent = text.lineAt(i); | |
| const matches = lineContent.matchAll(regex); | |
| for (const match of matches) { | |
| result.push(new SearchMatch(i, lineContent, match.index, match[0].length)); | |
| } | |
| } | |
| return result; | |
| }; | |
| /** | |
| * Similar to {@link performSearchInContent} but doesn't search in a whole text but rather | |
| * finds the exact matches on a prelminiary search result (i.e. lines with known matches). | |
| * @param matches is deliberatedly typed as an object literal so we can pass the | |
| * CDP search result type. | |
| */ | |
| export const performSearchInSearchMatches = function(matches: Array<{lineNumber: number, lineContent: string}>, | |
| query: string, caseSensitive: boolean, | |
| isRegex: boolean): SearchMatch[] { | |
| const regex = Platform.StringUtilities.createSearchRegex(query, caseSensitive, isRegex); | |
| const result = []; | |
| for (const {lineNumber, lineContent} of matches) { | |
| const matches = lineContent.matchAll(regex); | |
| for (const match of matches) { | |
| result.push(new SearchMatch(lineNumber, lineContent, match.index, match[0].length)); | |
| } | |
| } | |
| return result; | |
| }; | |
| /** | |
| * Finds the longest overlapping string segment between the end of the first | |
| * string and the beginning of the second string. | |
| * | |
| * @param s1 The first string (whose suffix will be checked). | |
| * @param s2 The second string (whose prefix will be checked). | |
| * @returns The overlapping string segment, or an empty string ("") | |
| * if no overlap is found. | |
| */ | |
| export const getOverlap = function(s1: string, s2: string): string|null { | |
| const minLen = Math.min(s1.length, s2.length); | |
| // Check from longest possible overlap down to 1 | |
| for (let n = minLen; n > 0; n--) { | |
| // slice(-n) gets the last 'n' chars | |
| const suffix = s1.slice(-n); | |
| // substring(0, n) gets the first 'n' chars | |
| const prefix = s2.substring(0, n); | |
| if (suffix === prefix) { | |
| return suffix; | |
| } | |
| } | |
| return null; | |
| }; | |
| export interface ParsedFilter { | |
| key?: string; | |
| text?: string|null; | |
| regex?: RegExp; | |
| negative: boolean; | |
| } |