codemirrorsearchprovider.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. /*
  4. Parts of the implementation of the search in this file were derived from
  5. CodeMirror's search at:
  6. https://github.com/codemirror/CodeMirror/blob/c2676685866c571a1c9c82cb25018cc08b4d42b2/addon/search/search.js
  7. which is licensed with the following license:
  8. MIT License
  9. Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
  10. Permission is hereby granted, free of charge, to any person obtaining a copy
  11. of this software and associated documentation files (the "Software"), to deal
  12. in the Software without restriction, including without limitation the rights
  13. to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  14. copies of the Software, and to permit persons to whom the Software is
  15. furnished to do so, subject to the following conditions:
  16. The above copyright notice and this permission notice shall be included in
  17. all copies or substantial portions of the Software.
  18. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  19. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  20. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  21. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  22. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  23. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  24. THE SOFTWARE.
  25. */
  26. import * as CodeMirror from 'codemirror';
  27. import { ISearchProvider, ISearchMatch } from '../index';
  28. import { CodeMirrorEditor } from '@jupyterlab/codemirror';
  29. import { CodeEditor } from '@jupyterlab/codeeditor';
  30. import { ISignal, Signal } from '@phosphor/signaling';
  31. type MatchMap = { [key: number]: { [key: number]: ISearchMatch } };
  32. export class CodeMirrorSearchProvider implements ISearchProvider {
  33. /**
  34. * Initialize the search using the provided options. Should update the UI
  35. * to highlight all matches and "select" whatever the first match should be.
  36. *
  37. * @param query A RegExp to be use to perform the search
  38. * @param searchTarget The widget to be searched
  39. *
  40. * @returns A promise that resolves with a list of all matches
  41. */
  42. async startQuery(query: RegExp, searchTarget: any): Promise<ISearchMatch[]> {
  43. if (searchTarget instanceof CodeMirrorEditor) {
  44. this._cm = searchTarget;
  45. } else if (searchTarget) {
  46. this._cm = searchTarget.content.editor;
  47. }
  48. await this.endQuery();
  49. this._query = query;
  50. CodeMirror.on(this._cm.doc, 'change', this._onDocChanged.bind(this));
  51. this._refreshOverlay();
  52. this._setInitialMatches(query);
  53. const matches = this._parseMatchesFromState();
  54. if (matches.length === 0) {
  55. return [];
  56. }
  57. if (!this.isSubProvider) {
  58. const cursorMatch = this._findNext(false);
  59. const match = this._matchState[cursorMatch.from.line][
  60. cursorMatch.from.ch
  61. ];
  62. this._matchIndex = match.index;
  63. }
  64. return matches;
  65. }
  66. /**
  67. * Clears state of a search provider to prepare for startQuery to be called
  68. * in order to start a new query or refresh an existing one.
  69. *
  70. * @returns A promise that resolves when the search provider is ready to
  71. * begin a new search.
  72. */
  73. async endQuery(): Promise<void> {
  74. this._matchState = {};
  75. this._matchIndex = null;
  76. this._cm.removeOverlay(this._overlay);
  77. CodeMirror.off(this._cm.doc, 'change', this._onDocChanged.bind(this));
  78. }
  79. /**
  80. * Resets UI state, removes all matches.
  81. *
  82. * @returns A promise that resolves when all state has been cleaned up.
  83. */
  84. async endSearch(): Promise<void> {
  85. if (!this.isSubProvider) {
  86. this._cm.focus();
  87. }
  88. this.endQuery();
  89. }
  90. /**
  91. * Move the current match indicator to the next match.
  92. *
  93. * @returns A promise that resolves once the action has completed.
  94. */
  95. async highlightNext(): Promise<ISearchMatch | undefined> {
  96. const cursorMatch = this._findNext(false);
  97. if (!cursorMatch) {
  98. return;
  99. }
  100. const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
  101. this._matchIndex = match.index;
  102. return match;
  103. }
  104. /**
  105. * Move the current match indicator to the previous match.
  106. *
  107. * @returns A promise that resolves once the action has completed.
  108. */
  109. async highlightPrevious(): Promise<ISearchMatch | undefined> {
  110. const cursorMatch = this._findNext(true);
  111. if (!cursorMatch) {
  112. return;
  113. }
  114. const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
  115. this._matchIndex = match.index;
  116. return match;
  117. }
  118. /**
  119. * Report whether or not this provider has the ability to search on the given object
  120. */
  121. static canSearchOn(domain: any): boolean {
  122. return domain.content && domain.content.editor instanceof CodeMirrorEditor;
  123. }
  124. /**
  125. * The same list of matches provided by the startQuery promise resoluton
  126. */
  127. get matches(): ISearchMatch[] {
  128. return this._parseMatchesFromState();
  129. }
  130. /**
  131. * Signal indicating that something in the search has changed, so the UI should update
  132. */
  133. get changed(): ISignal<this, void> {
  134. return this._changed;
  135. }
  136. /**
  137. * The current index of the selected match.
  138. */
  139. get currentMatchIndex(): number {
  140. return this._matchIndex;
  141. }
  142. clearSelection(): void {
  143. return null;
  144. }
  145. /**
  146. * Set whether or not the CodemirrorSearchProvider will wrap to the beginning
  147. * or end of the document on invocations of highlightNext or highlightPrevious, respectively
  148. */
  149. isSubProvider = false;
  150. private _onDocChanged(_: any, changeObj: CodeMirror.EditorChange) {
  151. // If we get newlines added/removed, the line numbers across the
  152. // match state are all shifted, so here we need to recalculate it
  153. if (changeObj.text.length > 1 || changeObj.removed.length > 1) {
  154. this._setInitialMatches(this._query);
  155. this._changed.emit(undefined);
  156. }
  157. }
  158. private _refreshOverlay() {
  159. this._cm.operation(() => {
  160. // clear search first
  161. this._cm.removeOverlay(this._overlay);
  162. this._overlay = this._getSearchOverlay();
  163. this._cm.addOverlay(this._overlay);
  164. this._changed.emit(null);
  165. });
  166. }
  167. /**
  168. * Do a full search on the entire document.
  169. *
  170. * This manually constructs the initial match state across the whole
  171. * document. This must be done manually because the codemirror overlay
  172. * is lazy-loaded, so it will only tokenize lines that are in or near
  173. * the viewport. This is sufficient for efficiently maintaining the
  174. * state when changes are made to the document, as changes occur in or
  175. * near the viewport, but to scan the whole document, a manual search
  176. * across the entire content is required.
  177. *
  178. * @param query The search term
  179. */
  180. private _setInitialMatches(query: RegExp) {
  181. this._matchState = {};
  182. const start = CodeMirror.Pos(this._cm.doc.firstLine(), 0);
  183. const end = CodeMirror.Pos(this._cm.doc.lastLine());
  184. const content = this._cm.doc.getRange(start, end);
  185. const lines = content.split('\n');
  186. let totalMatchIndex = 0;
  187. lines.forEach((line, lineNumber) => {
  188. query.lastIndex = 0;
  189. let match = query.exec(line);
  190. while (match) {
  191. const col = match.index;
  192. const matchObj: ISearchMatch = {
  193. text: match[0],
  194. line: lineNumber,
  195. column: col,
  196. fragment: line,
  197. index: totalMatchIndex
  198. };
  199. if (!this._matchState[lineNumber]) {
  200. this._matchState[lineNumber] = {};
  201. }
  202. this._matchState[lineNumber][col] = matchObj;
  203. match = query.exec(line);
  204. }
  205. });
  206. }
  207. private _getSearchOverlay() {
  208. return {
  209. /**
  210. * Token function is called when a line needs to be processed -
  211. * when the overlay is intially created, it's called on all lines;
  212. * when a line is modified and needs to be re-evaluated, it's called
  213. * on just that line.
  214. *
  215. * This implementation of the token function both constructs/maintains
  216. * the overlay and keeps track of the match state as the document is
  217. * updated while a search is active.
  218. */
  219. token: (stream: CodeMirror.StringStream) => {
  220. const currentPos = stream.pos;
  221. this._query.lastIndex = currentPos;
  222. const lineText = stream.string;
  223. const match = this._query.exec(lineText);
  224. const line = (stream as any).lineOracle.line;
  225. // If starting at position 0, the tokenization of this line has just started.
  226. // Blow away everything on this line in the state so it can be updated.
  227. if (
  228. stream.start === currentPos &&
  229. currentPos === 0 &&
  230. !!this._matchState[line]
  231. ) {
  232. this._matchState[line] = {};
  233. }
  234. if (match && match.index === currentPos) {
  235. // found match, add it to state
  236. const matchLength = match[0].length;
  237. const matchObj: ISearchMatch = {
  238. text: lineText.substr(currentPos, matchLength),
  239. line: line,
  240. column: currentPos,
  241. fragment: lineText,
  242. index: 0 // fill in index when flattening, later
  243. };
  244. if (!this._matchState[line]) {
  245. this._matchState[line] = {};
  246. }
  247. this._matchState[line][currentPos] = matchObj;
  248. // move the stream along and return searching style for the token
  249. stream.pos += matchLength || 1;
  250. // if the last thing on the line was a match, make sure we still
  251. // emit the changed signal so the display can pick up the updates
  252. if (stream.eol) {
  253. this._changed.emit(undefined);
  254. }
  255. return 'searching';
  256. } else if (match) {
  257. // there's a match in the stream, advance the stream to its position
  258. stream.pos = match.index;
  259. } else {
  260. // no matches, consume the rest of the stream
  261. this._changed.emit(undefined);
  262. stream.skipToEnd();
  263. }
  264. }
  265. };
  266. }
  267. private _findNext(reverse: boolean): Private.ICodeMirrorMatch {
  268. return this._cm.operation(() => {
  269. const caseSensitive = this._query.ignoreCase;
  270. const cursorToGet = reverse ? 'from' : 'to';
  271. const lastPosition = this._cm.getCursor(cursorToGet);
  272. const position = this._toEditorPos(lastPosition);
  273. let cursor: CodeMirror.SearchCursor = this._cm.getSearchCursor(
  274. this._query,
  275. lastPosition,
  276. !caseSensitive
  277. );
  278. if (!cursor.find(reverse)) {
  279. // if we don't want to loop, no more matches found, reset the cursor and exit
  280. if (this.isSubProvider) {
  281. this._cm.setCursorPosition(position);
  282. this._matchIndex = null;
  283. return null;
  284. }
  285. // if we do want to loop, try searching from the bottom/top
  286. const startOrEnd = reverse
  287. ? CodeMirror.Pos(this._cm.lastLine())
  288. : CodeMirror.Pos(this._cm.firstLine(), 0);
  289. cursor = this._cm.getSearchCursor(
  290. this._query,
  291. startOrEnd,
  292. !caseSensitive
  293. );
  294. if (!cursor.find(reverse)) {
  295. return null;
  296. }
  297. }
  298. const fromPos: CodeMirror.Position = cursor.from();
  299. const toPos: CodeMirror.Position = cursor.to();
  300. const selRange: CodeEditor.IRange = {
  301. start: {
  302. line: fromPos.line,
  303. column: fromPos.ch
  304. },
  305. end: {
  306. line: toPos.line,
  307. column: toPos.ch
  308. }
  309. };
  310. this._cm.setSelection(selRange);
  311. this._cm.scrollIntoView(
  312. {
  313. from: fromPos,
  314. to: toPos
  315. },
  316. 100
  317. );
  318. return {
  319. from: fromPos,
  320. to: toPos
  321. };
  322. });
  323. }
  324. private _parseMatchesFromState(): ISearchMatch[] {
  325. let index = 0;
  326. // Flatten state map and update the index of each match
  327. const matches: ISearchMatch[] = Object.keys(this._matchState).reduce(
  328. (result: ISearchMatch[], lineNumber: string) => {
  329. const lineKey = parseInt(lineNumber, 10);
  330. const lineMatches: { [key: number]: ISearchMatch } = this._matchState[
  331. lineKey
  332. ];
  333. Object.keys(lineMatches).forEach((pos: string) => {
  334. const posKey = parseInt(pos, 10);
  335. const match: ISearchMatch = lineMatches[posKey];
  336. match.index = index;
  337. index += 1;
  338. result.push(match);
  339. });
  340. return result;
  341. },
  342. []
  343. );
  344. return matches;
  345. }
  346. private _toEditorPos(posIn: CodeMirror.Position): CodeEditor.IPosition {
  347. return {
  348. line: posIn.line,
  349. column: posIn.ch
  350. };
  351. }
  352. private _query: RegExp;
  353. private _cm: CodeMirrorEditor;
  354. private _matchIndex: number;
  355. private _matchState: MatchMap = {};
  356. private _changed = new Signal<this, void>(this);
  357. private _overlay: any;
  358. }
  359. export class SearchState {
  360. public posFrom: CodeMirror.Position;
  361. public posTo: CodeMirror.Position;
  362. public lastQuery: string;
  363. public query: RegExp;
  364. }
  365. namespace Private {
  366. export interface ICodeMirrorMatch {
  367. from: CodeMirror.Position;
  368. to: CodeMirror.Position;
  369. }
  370. }