123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- // Copyright (c) Jupyter Development Team.
- // Distributed under the terms of the Modified BSD License.
- /*
- Parts of the implementation of the search in this file were derived from
- CodeMirror's search at:
- https://github.com/codemirror/CodeMirror/blob/c2676685866c571a1c9c82cb25018cc08b4d42b2/addon/search/search.js
- which is licensed with the following license:
- MIT License
- Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in
- all copies or substantial portions of the Software.
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- THE SOFTWARE.
- */
- import * as CodeMirror from 'codemirror';
- import { ISearchProvider, ISearchMatch } from '../index';
- import { CodeMirrorEditor } from '@jupyterlab/codemirror';
- import { CodeEditor } from '@jupyterlab/codeeditor';
- import { ISignal, Signal } from '@phosphor/signaling';
- type MatchMap = { [key: number]: { [key: number]: ISearchMatch } };
- export class CodeMirrorSearchProvider implements ISearchProvider {
- /**
- * Initialize the search using the provided options. Should update the UI
- * to highlight all matches and "select" whatever the first match should be.
- *
- * @param query A RegExp to be use to perform the search
- * @param searchTarget The widget to be searched
- *
- * @returns A promise that resolves with a list of all matches
- */
- async startQuery(query: RegExp, searchTarget: any): Promise<ISearchMatch[]> {
- if (searchTarget instanceof CodeMirrorEditor) {
- this._cm = searchTarget;
- } else if (searchTarget) {
- this._cm = searchTarget.content.editor;
- }
- await this.endQuery();
- this._query = query;
- CodeMirror.on(this._cm.doc, 'change', this._onDocChanged.bind(this));
- this._refreshOverlay();
- this._setInitialMatches(query);
- const matches = this._parseMatchesFromState();
- if (matches.length === 0) {
- return [];
- }
- if (!this.isSubProvider) {
- const cursorMatch = this._findNext(false);
- const match = this._matchState[cursorMatch.from.line][
- cursorMatch.from.ch
- ];
- this._matchIndex = match.index;
- }
- return matches;
- }
- /**
- * Clears state of a search provider to prepare for startQuery to be called
- * in order to start a new query or refresh an existing one.
- *
- * @returns A promise that resolves when the search provider is ready to
- * begin a new search.
- */
- async endQuery(): Promise<void> {
- this._matchState = {};
- this._matchIndex = null;
- this._cm.removeOverlay(this._overlay);
- CodeMirror.off(this._cm.doc, 'change', this._onDocChanged.bind(this));
- }
- /**
- * Resets UI state, removes all matches.
- *
- * @returns A promise that resolves when all state has been cleaned up.
- */
- async endSearch(): Promise<void> {
- if (!this.isSubProvider) {
- this._cm.focus();
- }
- this.endQuery();
- }
- /**
- * Move the current match indicator to the next match.
- *
- * @returns A promise that resolves once the action has completed.
- */
- async highlightNext(): Promise<ISearchMatch | undefined> {
- const cursorMatch = this._findNext(false);
- if (!cursorMatch) {
- return;
- }
- const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
- this._matchIndex = match.index;
- return match;
- }
- /**
- * Move the current match indicator to the previous match.
- *
- * @returns A promise that resolves once the action has completed.
- */
- async highlightPrevious(): Promise<ISearchMatch | undefined> {
- const cursorMatch = this._findNext(true);
- if (!cursorMatch) {
- return;
- }
- const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
- this._matchIndex = match.index;
- return match;
- }
- /**
- * Report whether or not this provider has the ability to search on the given object
- */
- static canSearchOn(domain: any): boolean {
- return domain.content && domain.content.editor instanceof CodeMirrorEditor;
- }
- /**
- * The same list of matches provided by the startQuery promise resoluton
- */
- get matches(): ISearchMatch[] {
- return this._parseMatchesFromState();
- }
- /**
- * Signal indicating that something in the search has changed, so the UI should update
- */
- get changed(): ISignal<this, void> {
- return this._changed;
- }
- /**
- * The current index of the selected match.
- */
- get currentMatchIndex(): number {
- return this._matchIndex;
- }
- clearSelection(): void {
- return null;
- }
- /**
- * Set whether or not the CodemirrorSearchProvider will wrap to the beginning
- * or end of the document on invocations of highlightNext or highlightPrevious, respectively
- */
- isSubProvider = false;
- private _onDocChanged(_: any, changeObj: CodeMirror.EditorChange) {
- // If we get newlines added/removed, the line numbers across the
- // match state are all shifted, so here we need to recalculate it
- if (changeObj.text.length > 1 || changeObj.removed.length > 1) {
- this._setInitialMatches(this._query);
- this._changed.emit(undefined);
- }
- }
- private _refreshOverlay() {
- this._cm.operation(() => {
- // clear search first
- this._cm.removeOverlay(this._overlay);
- this._overlay = this._getSearchOverlay();
- this._cm.addOverlay(this._overlay);
- this._changed.emit(null);
- });
- }
- /**
- * Do a full search on the entire document.
- *
- * This manually constructs the initial match state across the whole
- * document. This must be done manually because the codemirror overlay
- * is lazy-loaded, so it will only tokenize lines that are in or near
- * the viewport. This is sufficient for efficiently maintaining the
- * state when changes are made to the document, as changes occur in or
- * near the viewport, but to scan the whole document, a manual search
- * across the entire content is required.
- *
- * @param query The search term
- */
- private _setInitialMatches(query: RegExp) {
- this._matchState = {};
- const start = CodeMirror.Pos(this._cm.doc.firstLine(), 0);
- const end = CodeMirror.Pos(this._cm.doc.lastLine());
- const content = this._cm.doc.getRange(start, end);
- const lines = content.split('\n');
- let totalMatchIndex = 0;
- lines.forEach((line, lineNumber) => {
- query.lastIndex = 0;
- let match = query.exec(line);
- while (match) {
- const col = match.index;
- const matchObj: ISearchMatch = {
- text: match[0],
- line: lineNumber,
- column: col,
- fragment: line,
- index: totalMatchIndex
- };
- if (!this._matchState[lineNumber]) {
- this._matchState[lineNumber] = {};
- }
- this._matchState[lineNumber][col] = matchObj;
- match = query.exec(line);
- }
- });
- }
- private _getSearchOverlay() {
- return {
- /**
- * Token function is called when a line needs to be processed -
- * when the overlay is intially created, it's called on all lines;
- * when a line is modified and needs to be re-evaluated, it's called
- * on just that line.
- *
- * This implementation of the token function both constructs/maintains
- * the overlay and keeps track of the match state as the document is
- * updated while a search is active.
- */
- token: (stream: CodeMirror.StringStream) => {
- const currentPos = stream.pos;
- this._query.lastIndex = currentPos;
- const lineText = stream.string;
- const match = this._query.exec(lineText);
- const line = (stream as any).lineOracle.line;
- // If starting at position 0, the tokenization of this line has just started.
- // Blow away everything on this line in the state so it can be updated.
- if (
- stream.start === currentPos &&
- currentPos === 0 &&
- !!this._matchState[line]
- ) {
- this._matchState[line] = {};
- }
- if (match && match.index === currentPos) {
- // found match, add it to state
- const matchLength = match[0].length;
- const matchObj: ISearchMatch = {
- text: lineText.substr(currentPos, matchLength),
- line: line,
- column: currentPos,
- fragment: lineText,
- index: 0 // fill in index when flattening, later
- };
- if (!this._matchState[line]) {
- this._matchState[line] = {};
- }
- this._matchState[line][currentPos] = matchObj;
- // move the stream along and return searching style for the token
- stream.pos += matchLength || 1;
- // if the last thing on the line was a match, make sure we still
- // emit the changed signal so the display can pick up the updates
- if (stream.eol) {
- this._changed.emit(undefined);
- }
- return 'searching';
- } else if (match) {
- // there's a match in the stream, advance the stream to its position
- stream.pos = match.index;
- } else {
- // no matches, consume the rest of the stream
- this._changed.emit(undefined);
- stream.skipToEnd();
- }
- }
- };
- }
- private _findNext(reverse: boolean): Private.ICodeMirrorMatch {
- return this._cm.operation(() => {
- const caseSensitive = this._query.ignoreCase;
- const cursorToGet = reverse ? 'from' : 'to';
- const lastPosition = this._cm.getCursor(cursorToGet);
- const position = this._toEditorPos(lastPosition);
- let cursor: CodeMirror.SearchCursor = this._cm.getSearchCursor(
- this._query,
- lastPosition,
- !caseSensitive
- );
- if (!cursor.find(reverse)) {
- // if we don't want to loop, no more matches found, reset the cursor and exit
- if (this.isSubProvider) {
- this._cm.setCursorPosition(position);
- this._matchIndex = null;
- return null;
- }
- // if we do want to loop, try searching from the bottom/top
- const startOrEnd = reverse
- ? CodeMirror.Pos(this._cm.lastLine())
- : CodeMirror.Pos(this._cm.firstLine(), 0);
- cursor = this._cm.getSearchCursor(
- this._query,
- startOrEnd,
- !caseSensitive
- );
- if (!cursor.find(reverse)) {
- return null;
- }
- }
- const fromPos: CodeMirror.Position = cursor.from();
- const toPos: CodeMirror.Position = cursor.to();
- const selRange: CodeEditor.IRange = {
- start: {
- line: fromPos.line,
- column: fromPos.ch
- },
- end: {
- line: toPos.line,
- column: toPos.ch
- }
- };
- this._cm.setSelection(selRange);
- this._cm.scrollIntoView(
- {
- from: fromPos,
- to: toPos
- },
- 100
- );
- return {
- from: fromPos,
- to: toPos
- };
- });
- }
- private _parseMatchesFromState(): ISearchMatch[] {
- let index = 0;
- // Flatten state map and update the index of each match
- const matches: ISearchMatch[] = Object.keys(this._matchState).reduce(
- (result: ISearchMatch[], lineNumber: string) => {
- const lineKey = parseInt(lineNumber, 10);
- const lineMatches: { [key: number]: ISearchMatch } = this._matchState[
- lineKey
- ];
- Object.keys(lineMatches).forEach((pos: string) => {
- const posKey = parseInt(pos, 10);
- const match: ISearchMatch = lineMatches[posKey];
- match.index = index;
- index += 1;
- result.push(match);
- });
- return result;
- },
- []
- );
- return matches;
- }
- private _toEditorPos(posIn: CodeMirror.Position): CodeEditor.IPosition {
- return {
- line: posIn.line,
- column: posIn.ch
- };
- }
- private _query: RegExp;
- private _cm: CodeMirrorEditor;
- private _matchIndex: number;
- private _matchState: MatchMap = {};
- private _changed = new Signal<this, void>(this);
- private _overlay: any;
- }
- export class SearchState {
- public posFrom: CodeMirror.Position;
- public posTo: CodeMirror.Position;
- public lastQuery: string;
- public query: RegExp;
- }
- namespace Private {
- export interface ICodeMirrorMatch {
- from: CodeMirror.Position;
- to: CodeMirror.Position;
- }
- }
|