pax_global_header00006660000000000000000000000064145606762570014534gustar00rootroot0000000000000052 comment=0d8af3e4cc6e9e9bc2350952698015ff639ae857 search-6.5.6/000077500000000000000000000000001456067625700130175ustar00rootroot00000000000000search-6.5.6/.github/000077500000000000000000000000001456067625700143575ustar00rootroot00000000000000search-6.5.6/.github/workflows/000077500000000000000000000000001456067625700164145ustar00rootroot00000000000000search-6.5.6/.github/workflows/dispatch.yml000066400000000000000000000006371456067625700207440ustar00rootroot00000000000000name: Trigger CI on: push jobs: build: name: Dispatch to main repo runs-on: ubuntu-latest steps: - name: Emit repository_dispatch uses: mvasigh/dispatch-action@main with: # You should create a personal access token and store it in your repository token: ${{ secrets.DISPATCH_AUTH }} repo: dev owner: codemirror event_type: push search-6.5.6/.gitignore000066400000000000000000000001271456067625700150070ustar00rootroot00000000000000/node_modules package-lock.json /dist /test/*.js /test/*.d.ts /test/*.d.ts.map .tern-* search-6.5.6/.npmignore000066400000000000000000000001001456067625700150050ustar00rootroot00000000000000/src /test /node_modules .tern-* rollup.config.js tsconfig.json search-6.5.6/CHANGELOG.md000066400000000000000000000174321456067625700146370ustar00rootroot00000000000000## 6.5.6 (2024-02-07) ### Bug fixes Make `highlightSelectionMatches` include whitespace in the selection in its matches. Fix a bug that caused `SearchCursor` to return invalid ranges when matching astral chars that the the normalizer normalized to single-code-unit chars. ## 6.5.5 (2023-11-27) ### Bug fixes Fix a bug that caused codes like `\n` to be unescaped in strings inserted via replace placeholders like `$&`. Use the keybinding Mod-Alt-g for `gotoLine` to the search keymap, to make it usable for people whose keyboard layout uses Alt/Option-g to type some character. ## 6.5.4 (2023-09-20) ### Bug fixes Fix a bug that caused whole-word search to incorrectly check for word boundaries in some circumstances. ## 6.5.3 (2023-09-14) ### Bug fixes The `gotoLine` dialog is now populated with the current line number when you open it. ## 6.5.2 (2023-08-26) ### Bug fixes Don't use the very lowest precedence for match highlighting decorations. ## 6.5.1 (2023-08-04) ### Bug fixes Make `gotoLine` prefer to scroll the target line to the middle of the view. Fix an issue in `SearchCursor` where character normalization could produce nonsensical matches. ## 6.5.0 (2023-06-05) ### New features The new `regexp` option to `search` can be used to control whether queries have the regexp flag on by default. ## 6.4.0 (2023-04-25) ### Bug fixes The `findNext` and `findPrevious` commands now select the search field text if that field is focused. ### New features The `scrollToMatch` callback option now receives the editor view as a second parameter. ## 6.3.0 (2023-03-20) ### New features The new `scrollToMatch` search option allows you to adjust the way the editor scrolls search matches into view. ## 6.2.3 (2022-11-14) ### Bug fixes Fix a bug that hid the search dialog's close button when the editor was read-only. ## 6.2.2 (2022-10-18) ### Bug fixes When `literal` is off, \n, \r, and \t escapes are now also supported in replacement text. Make sure search dialog inputs don't get treated as form fields when the editor is created inside a form. Fix a bug in `RegExpCursor` that would cause it to stop matching in the middle of a line when its current match position was equal to the length of the line. ## 6.2.1 (2022-09-26) ### Bug fixes By-word search queries will now skip any result that had word characters both before and after a match boundary. ## 6.2.0 (2022-08-25) ### New features A new `wholeWord` search query flag can be used to limit matches to whole words. `SearchCursor` and `RegExpCursor` now support a `test` parameter that can be used to ignore certain matches. ## 6.1.0 (2022-08-16) ### Bug fixes Fix an infinite loop when the match position of a `RegExpCursor` ended up in the middle of an UTF16 surrogate pair. ### New features The `literal` search option can now be set to make literal queries the default. The new `searchPanelOpen` function can be used to find out whether the search panel is open for a given state. ## 6.0.1 (2022-07-22) ### Bug fixes `findNext` and `findPrevious` will now return to the current result (and scroll it into view) if no other matches are found. ## 6.0.0 (2022-06-08) ### Bug fixes Don't crash when a custom search panel doesn't have a field named 'search'. Make sure replacements are announced to screen readers. ## 0.20.1 (2022-04-22) ### New features It is now possible to disable backslash escapes in search queries with the `literal` option. ## 0.20.0 (2022-04-20) ### Bug fixes Make the `wholeWords` option to `highlightSelectionMatches` default to false, as intended. ## 0.19.10 (2022-04-04) ### Bug fixes Make sure search matches are highlighted when scrolling new content into view. ## 0.19.9 (2022-03-03) ### New features The selection-matching extension now accepts a `wholeWords` option that makes it only highlight matches that span a whole word. Add SearchQuery.getCursor The `SearchQuery` class now has a `getCursor` method that allows external code to create a cursor for the query. ## 0.19.8 (2022-02-14) ### Bug fixes Fix a bug that caused the search panel to start open when configuring a state with the `search()` extension. ## 0.19.7 (2022-02-14) ### Breaking changes `searchConfig` is deprecated in favor of `search` (but will exist until next major release). ### New features The new `search` function is now used to enable and configure the search extension. ## 0.19.6 (2022-01-27) ### Bug fixes Make `selectNextOccurrence` scroll the newly selected range into view. ## 0.19.5 (2021-12-16) ### Breaking changes The search option `matchCase` was renamed to `caseSensitive` (the old name will continue to work until the next breaking release). ### Bug fixes `openSearchPanel` will now update the search query to the current selection even if the panel was already open. ### New features Client code can now pass a custom search panel creation function in the search configuration. The `getSearchQuery` function and `setSearchQuery` effect can now be used to inspect or change the current search query. ## 0.19.4 (2021-12-02) ### Bug fixes The search panel will no longer show the replace interface when the editor is read-only. ## 0.19.3 (2021-11-22) ### Bug fixes Add `userEvent` annotations to search and replace transactions. Make sure the editor handles keys bound to `findNext`/`findPrevious` even when there are no matches, to avoid the browser's search interrupting users. ### New features Add a `Symbol.iterator` property to the cursor types, so that they can be used with `for`/`of`. ## 0.19.2 (2021-09-16) ### Bug fixes `selectNextOccurrence` will now only select partial words if the current main selection hold a partial word. Explicitly set the button's type to prevent the browser from submitting forms wrapped around the editor. ## 0.19.1 (2021-09-06) ### Bug fixes Make `highlightSelectionMatches` not produce overlapping decorations, since those tend to just get unreadable. Make sure any existing search text is selected when opening the search panel. Add search config option to not match case when search panel is opened (#4) ### New features The `searchConfig` function now takes a `matchCase` option that controls whether the search panel starts in case-sensitive mode. ## 0.19.0 (2021-08-11) ### Bug fixes Make sure to prevent the native Mod-d behavior so that the editor doesn't lose focus after selecting past the last occurrence. ## 0.18.4 (2021-05-27) ### New features Initialize the search query to the current selection, when there is one, when opening the search dialog. Add a `searchConfig` function, supporting an option to put the search panel at the top of the editor. ## 0.18.3 (2021-05-18) ### Bug fixes Fix a bug where the first search command in a new editor wouldn't properly open the panel. ### New features New command `selectNextOccurrence` that selects the next occurrence of the selected word (bound to Mod-d in the search keymap). ## 0.18.2 (2021-03-19) ### Bug fixes The search interface and cursor will no longer include overlapping matches (aligning with what all other editors are doing). ### New features The package now exports a `RegExpCursor` which is a search cursor that matches regular expression patterns. The search/replace interface now allows the user to use regular expressions. The `SearchCursor` class now has a `nextOverlapping` method that includes matches that start inside the previous match. Basic backslash escapes (\n, \r, \t, and \\) are now accepted in string search patterns in the UI. ## 0.18.1 (2021-03-15) ### Bug fixes Fix an issue where entering an invalid input in the goto-line dialog would submit a form and reload the page. ## 0.18.0 (2021-03-03) ### Breaking changes Update dependencies to 0.18. ## 0.17.1 (2021-01-06) ### New features The package now also exports a CommonJS module. ## 0.17.0 (2020-12-29) ### Breaking changes First numbered release. search-6.5.6/LICENSE000066400000000000000000000021361456067625700140260ustar00rootroot00000000000000MIT License Copyright (C) 2018-2021 by Marijn Haverbeke 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. search-6.5.6/README.md000066400000000000000000000017661456067625700143100ustar00rootroot00000000000000# @codemirror/search [![NPM version](https://img.shields.io/npm/v/@codemirror/search.svg)](https://www.npmjs.org/package/@codemirror/search) [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#search) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/search/blob/main/CHANGELOG.md) ] This package implements search functionality for the [CodeMirror](https://codemirror.net/) code editor. The [project page](https://codemirror.net/) has more information, a number of [examples](https://codemirror.net/examples/) and the [documentation](https://codemirror.net/docs/). This code is released under an [MIT license](https://github.com/codemirror/search/tree/main/LICENSE). We aim to be an inclusive, welcoming community. To make that explicit, we have a [code of conduct](http://contributor-covenant.org/version/1/1/0/) that applies to communication around the project. search-6.5.6/package.json000066400000000000000000000016241456067625700153100ustar00rootroot00000000000000{ "name": "@codemirror/search", "version": "6.5.6", "description": "Search functionality for the CodeMirror code editor", "scripts": { "test": "cm-runtests", "prepare": "cm-buildhelper src/search.ts" }, "keywords": [ "editor", "code" ], "author": { "name": "Marijn Haverbeke", "email": "marijn@haverbeke.berlin", "url": "http://marijnhaverbeke.nl" }, "type": "module", "main": "dist/index.cjs", "exports": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "types": "dist/index.d.ts", "module": "dist/index.js", "sideEffects": false, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" }, "devDependencies": { "@codemirror/buildhelper": "^1.0.0" }, "repository": { "type": "git", "url": "https://github.com/codemirror/search.git" } } search-6.5.6/src/000077500000000000000000000000001456067625700136065ustar00rootroot00000000000000search-6.5.6/src/README.md000066400000000000000000000005571456067625700150740ustar00rootroot00000000000000@searchKeymap @search ### Commands @findNext @findPrevious @selectMatches @selectSelectionMatches @selectNextOccurrence @replaceNext @replaceAll @openSearchPanel @closeSearchPanel @gotoLine ### Search Query @SearchQuery @getSearchQuery @setSearchQuery @searchPanelOpen ### Cursor @SearchCursor @RegExpCursor ### Selection matching @highlightSelectionMatches search-6.5.6/src/cursor.ts000066400000000000000000000104201456067625700154700ustar00rootroot00000000000000import {Text, TextIterator, codePointAt, codePointSize, fromCodePoint} from "@codemirror/state" const basicNormalize: (string: string) => string = typeof String.prototype.normalize == "function" ? x => x.normalize("NFKD") : x => x /// A search cursor provides an iterator over text matches in a /// document. export class SearchCursor implements Iterator<{from: number, to: number}>{ private iter: TextIterator /// The current match (only holds a meaningful value after /// [`next`](#search.SearchCursor.next) has been called and when /// `done` is false). value = {from: 0, to: 0} /// Whether the end of the iterated region has been reached. done = false private matches: number[] = [] private buffer = "" private bufferPos = 0 private bufferStart: number private normalize: (string: string) => string private query: string /// Create a text cursor. The query is the search string, `from` to /// `to` provides the region to search. /// /// When `normalize` is given, it will be called, on both the query /// string and the content it is matched against, before comparing. /// You can, for example, create a case-insensitive search by /// passing `s => s.toLowerCase()`. /// /// Text is always normalized with /// [`.normalize("NFKD")`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize) /// (when supported). constructor(text: Text, query: string, from: number = 0, to: number = text.length, normalize?: (string: string) => string, private test?: (from: number, to: number, buffer: string, bufferPos: number) => boolean) { this.iter = text.iterRange(from, to) this.bufferStart = from this.normalize = normalize ? x => normalize(basicNormalize(x)) : basicNormalize this.query = this.normalize(query) } private peek() { if (this.bufferPos == this.buffer.length) { this.bufferStart += this.buffer.length this.iter.next() if (this.iter.done) return -1 this.bufferPos = 0 this.buffer = this.iter.value } return codePointAt(this.buffer, this.bufferPos) } /// Look for the next match. Updates the iterator's /// [`value`](#search.SearchCursor.value) and /// [`done`](#search.SearchCursor.done) properties. Should be called /// at least once before using the cursor. next() { while (this.matches.length) this.matches.pop() return this.nextOverlapping() } /// The `next` method will ignore matches that partially overlap a /// previous match. This method behaves like `next`, but includes /// such matches. nextOverlapping() { for (;;) { let next = this.peek() if (next < 0) { this.done = true return this } let str = fromCodePoint(next), start = this.bufferStart + this.bufferPos this.bufferPos += codePointSize(next) let norm = this.normalize(str) for (let i = 0, pos = start;; i++) { let code = norm.charCodeAt(i) let match = this.match(code, pos, this.bufferPos + this.bufferStart) if (i == norm.length - 1) { if (match) { this.value = match return this } break } if (pos == start && i < str.length && str.charCodeAt(i) == code) pos++ } } } private match(code: number, pos: number, end: number) { let match: null | {from: number, to: number} = null for (let i = 0; i < this.matches.length; i += 2) { let index = this.matches[i], keep = false if (this.query.charCodeAt(index) == code) { if (index == this.query.length - 1) { match = {from: this.matches[i + 1], to: end} } else { this.matches[i]++ keep = true } } if (!keep) { this.matches.splice(i, 2) i -= 2 } } if (this.query.charCodeAt(0) == code) { if (this.query.length == 1) match = {from: pos, to: end} else this.matches.push(1, pos) } if (match && this.test && !this.test(match.from, match.to, this.buffer, this.bufferStart)) match = null return match } [Symbol.iterator]!: () => Iterator<{from: number, to: number}> } if (typeof Symbol != "undefined") SearchCursor.prototype[Symbol.iterator] = function(this: SearchCursor) { return this } search-6.5.6/src/goto-line.ts000066400000000000000000000062311456067625700160550ustar00rootroot00000000000000import {EditorSelection, StateField, StateEffect} from "@codemirror/state" import {EditorView, Command, Panel, getPanel, showPanel} from "@codemirror/view" import elt from "crelt" function createLineDialog(view: EditorView): Panel { let line = String(view.state.doc.lineAt(view.state.selection.main.head).number) let input = elt("input", {class: "cm-textfield", name: "line", value: line}) as HTMLInputElement let dom = elt("form", { class: "cm-gotoLine", onkeydown: (event: KeyboardEvent) => { if (event.keyCode == 27) { // Escape event.preventDefault() view.dispatch({effects: dialogEffect.of(false)}) view.focus() } else if (event.keyCode == 13) { // Enter event.preventDefault() go() } }, onsubmit: (event: Event) => { event.preventDefault() go() } }, elt("label", view.state.phrase("Go to line"), ": ", input), " ", elt("button", {class: "cm-button", type: "submit"}, view.state.phrase("go"))) function go() { let match = /^([+-])?(\d+)?(:\d+)?(%)?$/.exec(input.value) if (!match) return let {state} = view, startLine = state.doc.lineAt(state.selection.main.head) let [, sign, ln, cl, percent] = match let col = cl ? +cl.slice(1) : 0 let line = ln ? +ln : startLine.number if (ln && percent) { let pc = line / 100 if (sign) pc = pc * (sign == "-" ? -1 : 1) + (startLine.number / state.doc.lines) line = Math.round(state.doc.lines * pc) } else if (ln && sign) { line = line * (sign == "-" ? -1 : 1) + startLine.number } let docLine = state.doc.line(Math.max(1, Math.min(state.doc.lines, line))) let selection = EditorSelection.cursor(docLine.from + Math.max(0, Math.min(col, docLine.length))) view.dispatch({ effects: [dialogEffect.of(false), EditorView.scrollIntoView(selection.from, {y: 'center'})], selection, }) view.focus() } return {dom} } const dialogEffect = StateEffect.define() const dialogField = StateField.define({ create() { return true }, update(value, tr) { for (let e of tr.effects) if (e.is(dialogEffect)) value = e.value return value }, provide: f => showPanel.from(f, val => val ? createLineDialog : null) }) /// Command that shows a dialog asking the user for a line number, and /// when a valid position is provided, moves the cursor to that line. /// /// Supports line numbers, relative line offsets prefixed with `+` or /// `-`, document percentages suffixed with `%`, and an optional /// column position by adding `:` and a second number after the line /// number. export const gotoLine: Command = view => { let panel = getPanel(view, createLineDialog) if (!panel) { let effects: StateEffect[] = [dialogEffect.of(true)] if (view.state.field(dialogField, false) == null) effects.push(StateEffect.appendConfig.of([dialogField, baseTheme])) view.dispatch({effects}) panel = getPanel(view, createLineDialog) } if (panel) panel.dom.querySelector("input")!.select() return true } const baseTheme = EditorView.baseTheme({ ".cm-panel.cm-gotoLine": { padding: "2px 6px 4px", "& label": { fontSize: "80%" } } }) search-6.5.6/src/regexp.ts000066400000000000000000000151671456067625700154620ustar00rootroot00000000000000import {Text, TextIterator} from "@codemirror/state" const empty = {from: -1, to: -1, match: /.*/.exec("")!} const baseFlags = "gm" + (/x/.unicode == null ? "" : "u") export interface RegExpCursorOptions { ignoreCase?: boolean test?: (from: number, to: number, match: RegExpExecArray) => boolean } /// This class is similar to [`SearchCursor`](#search.SearchCursor) /// but searches for a regular expression pattern instead of a plain /// string. export class RegExpCursor implements Iterator<{from: number, to: number, match: RegExpExecArray}> { private iter!: TextIterator private re!: RegExp private test?: (from: number, to: number, match: RegExpExecArray) => boolean private curLine = "" private curLineStart!: number private matchPos!: number /// Set to `true` when the cursor has reached the end of the search /// range. done = false /// Will contain an object with the extent of the match and the /// match object when [`next`](#search.RegExpCursor.next) /// sucessfully finds a match. value = empty /// Create a cursor that will search the given range in the given /// document. `query` should be the raw pattern (as you'd pass it to /// `new RegExp`). constructor(private text: Text, query: string, options?: RegExpCursorOptions, from: number = 0, private to: number = text.length) { if (/\\[sWDnr]|\n|\r|\[\^/.test(query)) return new MultilineRegExpCursor(text, query, options, from, to) as any this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : "")) this.test = options?.test this.iter = text.iter() let startLine = text.lineAt(from) this.curLineStart = startLine.from this.matchPos = toCharEnd(text, from) this.getLine(this.curLineStart) } private getLine(skip: number) { this.iter.next(skip) if (this.iter.lineBreak) { this.curLine = "" } else { this.curLine = this.iter.value if (this.curLineStart + this.curLine.length > this.to) this.curLine = this.curLine.slice(0, this.to - this.curLineStart) this.iter.next() } } private nextLine() { this.curLineStart = this.curLineStart + this.curLine.length + 1 if (this.curLineStart > this.to) this.curLine = "" else this.getLine(0) } /// Move to the next match, if there is one. next() { for (let off = this.matchPos - this.curLineStart;;) { this.re.lastIndex = off let match = this.matchPos <= this.to && this.re.exec(this.curLine) if (match) { let from = this.curLineStart + match.index, to = from + match[0].length this.matchPos = toCharEnd(this.text, to + (from == to ? 1 : 0)) if (from == this.curLineStart + this.curLine.length) this.nextLine() if ((from < to || from > this.value.to) && (!this.test || this.test(from, to, match))) { this.value = {from, to, match} return this } off = this.matchPos - this.curLineStart } else if (this.curLineStart + this.curLine.length < this.to) { this.nextLine() off = 0 } else { this.done = true return this } } } [Symbol.iterator]!: () => Iterator<{from: number, to: number, match: RegExpExecArray}> } const flattened = new WeakMap() // Reusable (partially) flattened document strings class FlattenedDoc { constructor(readonly from: number, readonly text: string) {} get to() { return this.from + this.text.length } static get(doc: Text, from: number, to: number) { let cached = flattened.get(doc) if (!cached || cached.from >= to || cached.to <= from) { let flat = new FlattenedDoc(from, doc.sliceString(from, to)) flattened.set(doc, flat) return flat } if (cached.from == from && cached.to == to) return cached let {text, from: cachedFrom} = cached if (cachedFrom > from) { text = doc.sliceString(from, cachedFrom) + text cachedFrom = from } if (cached.to < to) text += doc.sliceString(cached.to, to) flattened.set(doc, new FlattenedDoc(cachedFrom, text)) return new FlattenedDoc(from, text.slice(from - cachedFrom, to - cachedFrom)) } } const enum Chunk { Base = 5000 } class MultilineRegExpCursor implements Iterator<{from: number, to: number, match: RegExpExecArray}> { private flat: FlattenedDoc private matchPos private re: RegExp private test?: (from: number, to: number, match: RegExpExecArray) => boolean done = false value = empty constructor(private text: Text, query: string, options: RegExpCursorOptions | undefined, from: number, private to: number) { this.matchPos = toCharEnd(text, from) this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : "")) this.test = options?.test this.flat = FlattenedDoc.get(text, from, this.chunkEnd(from + Chunk.Base)) } private chunkEnd(pos: number) { return pos >= this.to ? this.to : this.text.lineAt(pos).to } next() { for (;;) { let off = this.re.lastIndex = this.matchPos - this.flat.from let match = this.re.exec(this.flat.text) // Skip empty matches directly after the last match if (match && !match[0] && match.index == off) { this.re.lastIndex = off + 1 match = this.re.exec(this.flat.text) } if (match) { let from = this.flat.from + match.index, to = from + match[0].length // If a match goes almost to the end of a noncomplete chunk, try // again, since it'll likely be able to match more if ((this.flat.to >= this.to || match.index + match[0].length <= this.flat.text.length - 10) && (!this.test || this.test(from, to, match))) { this.value = {from, to, match} this.matchPos = toCharEnd(this.text, to + (from == to ? 1 : 0)) return this } } if (this.flat.to == this.to) { this.done = true return this } // Grow the flattened doc this.flat = FlattenedDoc.get(this.text, this.flat.from, this.chunkEnd(this.flat.from + this.flat.text.length * 2)) } } [Symbol.iterator]!: () => Iterator<{from: number, to: number, match: RegExpExecArray}> } if (typeof Symbol != "undefined") { RegExpCursor.prototype[Symbol.iterator] = MultilineRegExpCursor.prototype[Symbol.iterator] = function(this: RegExpCursor) { return this } } export function validRegExp(source: string) { try { new RegExp(source, baseFlags) return true } catch { return false } } function toCharEnd(text: Text, pos: number) { if (pos >= text.length) return pos let line = text.lineAt(pos), next while (pos < line.to && (next = line.text.charCodeAt(pos - line.from)) >= 0xDC00 && next < 0xE000) pos++ return pos } search-6.5.6/src/search.ts000066400000000000000000000705141456067625700154320ustar00rootroot00000000000000import {EditorView, ViewPlugin, ViewUpdate, Command, Decoration, DecorationSet, runScopeHandlers, KeyBinding, PanelConstructor, showPanel, Panel, getPanel} from "@codemirror/view" import {EditorState, StateField, StateEffect, EditorSelection, SelectionRange, StateCommand, Prec, Facet, Extension, RangeSetBuilder, Text, CharCategory, findClusterBreak, combineConfig} from "@codemirror/state" import elt from "crelt" import {SearchCursor} from "./cursor" import {RegExpCursor, validRegExp} from "./regexp" import {gotoLine} from "./goto-line" import {selectNextOccurrence} from "./selection-match" export {highlightSelectionMatches} from "./selection-match" export {SearchCursor, RegExpCursor, gotoLine, selectNextOccurrence} interface SearchConfig { /// Whether to position the search panel at the top of the editor /// (the default is at the bottom). top?: boolean /// Whether to enable case sensitivity by default when the search /// panel is activated (defaults to false). caseSensitive?: boolean /// Whether to treat string searches literally by default (defaults to false). literal?: boolean /// Controls whether the default query has by-word matching enabled. /// Defaults to false. wholeWord?: boolean /// Used to turn on regular expression search in the default query. /// Defaults to false. regexp?: boolean /// Can be used to override the way the search panel is implemented. /// Should create a [Panel](#view.Panel) that contains a form /// which lets the user: /// /// - See the [current](#search.getSearchQuery) search query. /// - Manipulate the [query](#search.SearchQuery) and /// [update](#search.setSearchQuery) the search state with a new /// query. /// - Notice external changes to the query by reacting to the /// appropriate [state effect](#search.setSearchQuery). /// - Run some of the search commands. /// /// The field that should be focused when opening the panel must be /// tagged with a `main-field=true` DOM attribute. createPanel?: (view: EditorView) => Panel, /// By default, matches are scrolled into view using the default /// behavior of /// [`EditorView.scrollIntoView`](#view.EditorView^scrollIntoView). /// This option allows you to pass a custom function to produce the /// scroll effect. scrollToMatch?: (range: SelectionRange, view: EditorView) => StateEffect } const searchConfigFacet: Facet> = Facet.define({ combine(configs) { return combineConfig(configs, { top: false, caseSensitive: false, literal: false, regexp: false, wholeWord: false, createPanel: view => new SearchPanel(view), scrollToMatch: range => EditorView.scrollIntoView(range) }) } }) /// Add search state to the editor configuration, and optionally /// configure the search extension. /// ([`openSearchPanel`](#search.openSearchPanel) will automatically /// enable this if it isn't already on). export function search(config?: SearchConfig): Extension { return config ? [searchConfigFacet.of(config), searchExtensions] : searchExtensions } /// A search query. Part of the editor's search state. export class SearchQuery { /// The search string (or regular expression). readonly search: string /// Indicates whether the search is case-sensitive. readonly caseSensitive: boolean /// By default, string search will replace `\n`, `\r`, and `\t` in /// the query with newline, return, and tab characters. When this /// is set to true, that behavior is disabled. readonly literal: boolean /// When true, the search string is interpreted as a regular /// expression. readonly regexp: boolean /// The replace text, or the empty string if no replace text has /// been given. readonly replace: string /// Whether this query is non-empty and, in case of a regular /// expression search, syntactically valid. readonly valid: boolean /// When true, matches that contain words are ignored when there are /// further word characters around them. readonly wholeWord: boolean /// @internal readonly unquoted: string /// Create a query object. constructor(config: { /// The search string. search: string, /// Controls whether the search should be case-sensitive. caseSensitive?: boolean, /// By default, string search will replace `\n`, `\r`, and `\t` in /// the query with newline, return, and tab characters. When this /// is set to true, that behavior is disabled. literal?: boolean, /// When true, interpret the search string as a regular expression. regexp?: boolean, /// The replace text. replace?: string, /// Enable whole-word matching. wholeWord?: boolean }) { this.search = config.search this.caseSensitive = !!config.caseSensitive this.literal = !!config.literal this.regexp = !!config.regexp this.replace = config.replace || "" this.valid = !!this.search && (!this.regexp || validRegExp(this.search)) this.unquoted = this.unquote(this.search) this.wholeWord = !!config.wholeWord } /// @internal unquote(text: string) { return this.literal ? text : text.replace(/\\([nrt\\])/g, (_, ch) => ch == "n" ? "\n" : ch == "r" ? "\r" : ch == "t" ? "\t" : "\\") } /// Compare this query to another query. eq(other: SearchQuery) { return this.search == other.search && this.replace == other.replace && this.caseSensitive == other.caseSensitive && this.regexp == other.regexp && this.wholeWord == other.wholeWord } /// @internal create(): QueryType { return this.regexp ? new RegExpQuery(this) : new StringQuery(this) } /// Get a search cursor for this query, searching through the given /// range in the given state. getCursor(state: EditorState | Text, from: number = 0, to?: number): Iterator<{from: number, to: number}> { let st = (state as any).doc ? state as EditorState : EditorState.create({doc: state as Text}) if (to == null) to = st.doc.length return this.regexp ? regexpCursor(this, st, from, to) : stringCursor(this, st, from, to) } } type SearchResult = typeof SearchCursor.prototype.value abstract class QueryType { constructor(readonly spec: SearchQuery) {} abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null abstract getReplacement(result: Result): string abstract matchAll(state: EditorState, limit: number): readonly Result[] | null abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void } const enum FindPrev { ChunkSize = 10000 } function stringCursor(spec: SearchQuery, state: EditorState, from: number, to: number) { return new SearchCursor( state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined) } function stringWordTest(doc: Text, categorizer: (ch: string) => CharCategory) { return (from: number, to: number, buf: string, bufPos: number) => { if (bufPos > from || bufPos + buf.length < to) { bufPos = Math.max(0, from - 2) buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2)) } return (categorizer(charBefore(buf, from - bufPos)) != CharCategory.Word || categorizer(charAfter(buf, from - bufPos)) != CharCategory.Word) && (categorizer(charAfter(buf, to - bufPos)) != CharCategory.Word || categorizer(charBefore(buf, to - bufPos)) != CharCategory.Word) } } class StringQuery extends QueryType { constructor(spec: SearchQuery) { super(spec) } nextMatch(state: EditorState, curFrom: number, curTo: number) { let cursor = stringCursor(this.spec, state, curTo, state.doc.length).nextOverlapping() if (cursor.done) cursor = stringCursor(this.spec, state, 0, curFrom).nextOverlapping() return cursor.done ? null : cursor.value } // Searching in reverse is, rather than implementing an inverted search // cursor, done by scanning chunk after chunk forward. private prevMatchInRange(state: EditorState, from: number, to: number) { for (let pos = to;;) { let start = Math.max(from, pos - FindPrev.ChunkSize - this.spec.unquoted.length) let cursor = stringCursor(this.spec, state, start, pos), range: SearchResult | null = null while (!cursor.nextOverlapping().done) range = cursor.value if (range) return range if (start == from) return null pos -= FindPrev.ChunkSize } } prevMatch(state: EditorState, curFrom: number, curTo: number) { return this.prevMatchInRange(state, 0, curFrom) || this.prevMatchInRange(state, curTo, state.doc.length) } getReplacement(_result: SearchResult) { return this.spec.unquote(this.spec.replace) } matchAll(state: EditorState, limit: number) { let cursor = stringCursor(this.spec, state, 0, state.doc.length), ranges = [] while (!cursor.next().done) { if (ranges.length >= limit) return null ranges.push(cursor.value) } return ranges } highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void) { let cursor = stringCursor(this.spec, state, Math.max(0, from - this.spec.unquoted.length), Math.min(to + this.spec.unquoted.length, state.doc.length)) while (!cursor.next().done) add(cursor.value.from, cursor.value.to) } } const enum RegExp { HighlightMargin = 250 } type RegExpResult = typeof RegExpCursor.prototype.value function regexpCursor(spec: SearchQuery, state: EditorState, from: number, to: number) { return new RegExpCursor(state.doc, spec.search, { ignoreCase: !spec.caseSensitive, test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined }, from, to) } function charBefore(str: string, index: number) { return str.slice(findClusterBreak(str, index, false), index) } function charAfter(str: string, index: number) { return str.slice(index, findClusterBreak(str, index)) } function regexpWordTest(categorizer: (ch: string) => CharCategory) { return (_from: number, _to: number, match: RegExpExecArray) => !match[0].length || (categorizer(charBefore(match.input, match.index)) != CharCategory.Word || categorizer(charAfter(match.input, match.index)) != CharCategory.Word) && (categorizer(charAfter(match.input, match.index + match[0].length)) != CharCategory.Word || categorizer(charBefore(match.input, match.index + match[0].length)) != CharCategory.Word) } class RegExpQuery extends QueryType { nextMatch(state: EditorState, curFrom: number, curTo: number) { let cursor = regexpCursor(this.spec, state, curTo, state.doc.length).next() if (cursor.done) cursor = regexpCursor(this.spec, state, 0, curFrom).next() return cursor.done ? null : cursor.value } private prevMatchInRange(state: EditorState, from: number, to: number) { for (let size = 1;; size++) { let start = Math.max(from, to - size * FindPrev.ChunkSize) let cursor = regexpCursor(this.spec, state, start, to), range: RegExpResult | null = null while (!cursor.next().done) range = cursor.value if (range && (start == from || range.from > start + 10)) return range if (start == from) return null } } prevMatch(state: EditorState, curFrom: number, curTo: number) { return this.prevMatchInRange(state, 0, curFrom) || this.prevMatchInRange(state, curTo, state.doc.length) } getReplacement(result: RegExpResult) { return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$" : i == "&" ? result.match[0] : i != "0" && +i < result.match.length ? result.match[i] : m) } matchAll(state: EditorState, limit: number) { let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [] while (!cursor.next().done) { if (ranges.length >= limit) return null ranges.push(cursor.value) } return ranges } highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void) { let cursor = regexpCursor(this.spec, state, Math.max(0, from - RegExp.HighlightMargin), Math.min(to + RegExp.HighlightMargin, state.doc.length)) while (!cursor.next().done) add(cursor.value.from, cursor.value.to) } } /// A state effect that updates the current search query. Note that /// this only has an effect if the search state has been initialized /// (by including [`search`](#search.search) in your configuration or /// by running [`openSearchPanel`](#search.openSearchPanel) at least /// once). export const setSearchQuery = StateEffect.define() const togglePanel = StateEffect.define() const searchState: StateField = StateField.define({ create(state) { return new SearchState(defaultQuery(state).create(), null) }, update(value, tr) { for (let effect of tr.effects) { if (effect.is(setSearchQuery)) value = new SearchState(effect.value.create(), value.panel) else if (effect.is(togglePanel)) value = new SearchState(value.query, effect.value ? createSearchPanel : null) } return value }, provide: f => showPanel.from(f, val => val.panel) }) /// Get the current search query from an editor state. export function getSearchQuery(state: EditorState) { let curState = state.field(searchState, false) return curState ? curState.query.spec : defaultQuery(state) } /// Query whether the search panel is open in the given editor state. export function searchPanelOpen(state: EditorState) { return state.field(searchState, false)?.panel != null } class SearchState { constructor(readonly query: QueryType, readonly panel: PanelConstructor | null) {} } const matchMark = Decoration.mark({class: "cm-searchMatch"}), selectedMatchMark = Decoration.mark({class: "cm-searchMatch cm-searchMatch-selected"}) const searchHighlighter = ViewPlugin.fromClass(class { decorations: DecorationSet constructor(readonly view: EditorView) { this.decorations = this.highlight(view.state.field(searchState)) } update(update: ViewUpdate) { let state = update.state.field(searchState) if (state != update.startState.field(searchState) || update.docChanged || update.selectionSet || update.viewportChanged) this.decorations = this.highlight(state) } highlight({query, panel}: SearchState) { if (!panel || !query.spec.valid) return Decoration.none let {view} = this let builder = new RangeSetBuilder() for (let i = 0, ranges = view.visibleRanges, l = ranges.length; i < l; i++) { let {from, to} = ranges[i] while (i < l - 1 && to > ranges[i + 1].from - 2 * RegExp.HighlightMargin) to = ranges[++i].to query.highlight(view.state, from, to, (from, to) => { let selected = view.state.selection.ranges.some(r => r.from == from && r.to == to) builder.add(from, to, selected ? selectedMatchMark : matchMark) }) } return builder.finish() } }, { decorations: v => v.decorations }) function searchCommand(f: (view: EditorView, state: SearchState) => boolean): Command { return view => { let state = view.state.field(searchState, false) return state && state.query.spec.valid ? f(view, state) : openSearchPanel(view) } } /// Open the search panel if it isn't already open, and move the /// selection to the first match after the current main selection. /// Will wrap around to the start of the document when it reaches the /// end. export const findNext = searchCommand((view, {query}) => { let {to} = view.state.selection.main let next = query.nextMatch(view.state, to, to) if (!next) return false let selection = EditorSelection.single(next.from, next.to) let config = view.state.facet(searchConfigFacet) view.dispatch({ selection, effects: [announceMatch(view, next), config.scrollToMatch(selection.main, view)], userEvent: "select.search" }) selectSearchInput(view) return true }) /// Move the selection to the previous instance of the search query, /// before the current main selection. Will wrap past the start /// of the document to start searching at the end again. export const findPrevious = searchCommand((view, {query}) => { let {state} = view, {from} = state.selection.main let prev = query.prevMatch(state, from, from) if (!prev) return false let selection = EditorSelection.single(prev.from, prev.to) let config = view.state.facet(searchConfigFacet) view.dispatch({ selection, effects: [announceMatch(view, prev), config.scrollToMatch(selection.main, view)], userEvent: "select.search" }) selectSearchInput(view) return true }) /// Select all instances of the search query. export const selectMatches = searchCommand((view, {query}) => { let ranges = query.matchAll(view.state, 1000) if (!ranges || !ranges.length) return false view.dispatch({ selection: EditorSelection.create(ranges.map(r => EditorSelection.range(r.from, r.to))), userEvent: "select.search.matches" }) return true }) /// Select all instances of the currently selected text. export const selectSelectionMatches: StateCommand = ({state, dispatch}) => { let sel = state.selection if (sel.ranges.length > 1 || sel.main.empty) return false let {from, to} = sel.main let ranges = [], main = 0 for (let cur = new SearchCursor(state.doc, state.sliceDoc(from, to)); !cur.next().done;) { if (ranges.length > 1000) return false if (cur.value.from == from) main = ranges.length ranges.push(EditorSelection.range(cur.value.from, cur.value.to)) } dispatch(state.update({ selection: EditorSelection.create(ranges, main), userEvent: "select.search.matches" })) return true } /// Replace the current match of the search query. export const replaceNext = searchCommand((view, {query}) => { let {state} = view, {from, to} = state.selection.main if (state.readOnly) return false let next = query.nextMatch(state, from, from) if (!next) return false let changes = [], selection: EditorSelection | undefined, replacement: Text | undefined let effects: StateEffect[] = [] if (next.from == from && next.to == to) { replacement = state.toText(query.getReplacement(next)) changes.push({from: next.from, to: next.to, insert: replacement}) next = query.nextMatch(state, next.from, next.to) effects.push(EditorView.announce.of( state.phrase("replaced match on line $", state.doc.lineAt(from).number) + ".")) } if (next) { let off = changes.length == 0 || changes[0].from >= next.to ? 0 : next.to - next.from - replacement!.length selection = EditorSelection.single(next.from - off, next.to - off) effects.push(announceMatch(view, next)) effects.push(state.facet(searchConfigFacet).scrollToMatch(selection.main, view)) } view.dispatch({ changes, selection, effects, userEvent: "input.replace" }) return true }) /// Replace all instances of the search query with the given /// replacement. export const replaceAll = searchCommand((view, {query}) => { if (view.state.readOnly) return false let changes = query.matchAll(view.state, 1e9)!.map(match => { let {from, to} = match return {from, to, insert: query.getReplacement(match)} }) if (!changes.length) return false let announceText = view.state.phrase("replaced $ matches", changes.length) + "." view.dispatch({ changes, effects: EditorView.announce.of(announceText), userEvent: "input.replace.all" }) return true }) function createSearchPanel(view: EditorView) { return view.state.facet(searchConfigFacet).createPanel(view) } function defaultQuery(state: EditorState, fallback?: SearchQuery) { let sel = state.selection.main let selText = sel.empty || sel.to > sel.from + 100 ? "" : state.sliceDoc(sel.from, sel.to) if (fallback && !selText) return fallback let config = state.facet(searchConfigFacet) return new SearchQuery({ search: (fallback?.literal ?? config.literal) ? selText : selText.replace(/\n/g, "\\n"), caseSensitive: fallback?.caseSensitive ?? config.caseSensitive, literal: fallback?.literal ?? config.literal, regexp: fallback?.regexp ?? config.regexp, wholeWord: fallback?.wholeWord ?? config.wholeWord }) } function getSearchInput(view: EditorView) { let panel = getPanel(view, createSearchPanel) return panel && panel.dom.querySelector("[main-field]") as HTMLInputElement | null } function selectSearchInput(view: EditorView) { let input = getSearchInput(view) if (input && input == view.root.activeElement) input.select() } /// Make sure the search panel is open and focused. export const openSearchPanel: Command = view => { let state = view.state.field(searchState, false) if (state && state.panel) { let searchInput = getSearchInput(view) if (searchInput && searchInput != view.root.activeElement) { let query = defaultQuery(view.state, state.query.spec) if (query.valid) view.dispatch({effects: setSearchQuery.of(query)}) searchInput.focus() searchInput.select() } } else { view.dispatch({effects: [ togglePanel.of(true), state ? setSearchQuery.of(defaultQuery(view.state, state.query.spec)) : StateEffect.appendConfig.of(searchExtensions) ]}) } return true } /// Close the search panel. export const closeSearchPanel: Command = view => { let state = view.state.field(searchState, false) if (!state || !state.panel) return false let panel = getPanel(view, createSearchPanel) if (panel && panel.dom.contains(view.root.activeElement)) view.focus() view.dispatch({effects: togglePanel.of(false)}) return true } /// Default search-related key bindings. /// /// - Mod-f: [`openSearchPanel`](#search.openSearchPanel) /// - F3, Mod-g: [`findNext`](#search.findNext) /// - Shift-F3, Shift-Mod-g: [`findPrevious`](#search.findPrevious) /// - Mod-Alt-g: [`gotoLine`](#search.gotoLine) /// - Mod-d: [`selectNextOccurrence`](#search.selectNextOccurrence) export const searchKeymap: readonly KeyBinding[] = [ {key: "Mod-f", run: openSearchPanel, scope: "editor search-panel"}, {key: "F3", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true}, {key: "Mod-g", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true}, {key: "Escape", run: closeSearchPanel, scope: "editor search-panel"}, {key: "Mod-Shift-l", run: selectSelectionMatches}, {key: "Mod-Alt-g", run: gotoLine}, {key: "Mod-d", run: selectNextOccurrence, preventDefault: true}, ] class SearchPanel implements Panel { searchField: HTMLInputElement replaceField: HTMLInputElement caseField: HTMLInputElement reField: HTMLInputElement wordField: HTMLInputElement dom: HTMLElement query: SearchQuery constructor(readonly view: EditorView) { let query = this.query = view.state.field(searchState).query.spec this.commit = this.commit.bind(this) this.searchField = elt("input", { value: query.search, placeholder: phrase(view, "Find"), "aria-label": phrase(view, "Find"), class: "cm-textfield", name: "search", form: "", "main-field": "true", onchange: this.commit, onkeyup: this.commit }) as HTMLInputElement this.replaceField = elt("input", { value: query.replace, placeholder: phrase(view, "Replace"), "aria-label": phrase(view, "Replace"), class: "cm-textfield", name: "replace", form: "", onchange: this.commit, onkeyup: this.commit }) as HTMLInputElement this.caseField = elt("input", { type: "checkbox", name: "case", form: "", checked: query.caseSensitive, onchange: this.commit }) as HTMLInputElement this.reField = elt("input", { type: "checkbox", name: "re", form: "", checked: query.regexp, onchange: this.commit }) as HTMLInputElement this.wordField = elt("input", { type: "checkbox", name: "word", form: "", checked: query.wholeWord, onchange: this.commit }) as HTMLInputElement function button(name: string, onclick: () => void, content: (Node | string)[]) { return elt("button", {class: "cm-button", name, onclick, type: "button"}, content) } this.dom = elt("div", {onkeydown: (e: KeyboardEvent) => this.keydown(e), class: "cm-search"}, [ this.searchField, button("next", () => findNext(view), [phrase(view, "next")]), button("prev", () => findPrevious(view), [phrase(view, "previous")]), button("select", () => selectMatches(view), [phrase(view, "all")]), elt("label", null, [this.caseField, phrase(view, "match case")]), elt("label", null, [this.reField, phrase(view, "regexp")]), elt("label", null, [this.wordField, phrase(view, "by word")]), ...view.state.readOnly ? [] : [ elt("br"), this.replaceField, button("replace", () => replaceNext(view), [phrase(view, "replace")]), button("replaceAll", () => replaceAll(view), [phrase(view, "replace all")]) ], elt("button", { name: "close", onclick: () => closeSearchPanel(view), "aria-label": phrase(view, "close"), type: "button" }, ["×"]) ]) } commit() { let query = new SearchQuery({ search: this.searchField.value, caseSensitive: this.caseField.checked, regexp: this.reField.checked, wholeWord: this.wordField.checked, replace: this.replaceField.value, }) if (!query.eq(this.query)) { this.query = query this.view.dispatch({effects: setSearchQuery.of(query)}) } } keydown(e: KeyboardEvent) { if (runScopeHandlers(this.view, e, "search-panel")) { e.preventDefault() } else if (e.keyCode == 13 && e.target == this.searchField) { e.preventDefault() ;(e.shiftKey ? findPrevious : findNext)(this.view) } else if (e.keyCode == 13 && e.target == this.replaceField) { e.preventDefault() replaceNext(this.view) } } update(update: ViewUpdate) { for (let tr of update.transactions) for (let effect of tr.effects) { if (effect.is(setSearchQuery) && !effect.value.eq(this.query)) this.setQuery(effect.value) } } setQuery(query: SearchQuery) { this.query = query this.searchField.value = query.search this.replaceField.value = query.replace this.caseField.checked = query.caseSensitive this.reField.checked = query.regexp this.wordField.checked = query.wholeWord } mount() { this.searchField.select() } get pos() { return 80 } get top() { return this.view.state.facet(searchConfigFacet).top } } function phrase(view: EditorView, phrase: string) { return view.state.phrase(phrase) } const AnnounceMargin = 30 const Break = /[\s\.,:;?!]/ function announceMatch(view: EditorView, {from, to}: {from: number, to: number}) { let line = view.state.doc.lineAt(from), lineEnd = view.state.doc.lineAt(to).to let start = Math.max(line.from, from - AnnounceMargin), end = Math.min(lineEnd, to + AnnounceMargin) let text = view.state.sliceDoc(start, end) if (start != line.from) { for (let i = 0; i < AnnounceMargin; i++) if (!Break.test(text[i + 1]) && Break.test(text[i])) { text = text.slice(i) break } } if (end != lineEnd) { for (let i = text.length - 1; i > text.length - AnnounceMargin; i--) if (!Break.test(text[i - 1]) && Break.test(text[i])) { text = text.slice(0, i) break } } return EditorView.announce.of( `${view.state.phrase("current match")}. ${text} ${view.state.phrase("on line")} ${line.number}.`) } const baseTheme = EditorView.baseTheme({ ".cm-panel.cm-search": { padding: "2px 6px 4px", position: "relative", "& [name=close]": { position: "absolute", top: "0", right: "4px", backgroundColor: "inherit", border: "none", font: "inherit", padding: 0, margin: 0 }, "& input, & button, & label": { margin: ".2em .6em .2em 0" }, "& input[type=checkbox]": { marginRight: ".2em" }, "& label": { fontSize: "80%", whiteSpace: "pre" } }, "&light .cm-searchMatch": { backgroundColor: "#ffff0054" }, "&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" }, "&light .cm-searchMatch-selected": { backgroundColor: "#ff6a0054" }, "&dark .cm-searchMatch-selected": { backgroundColor: "#ff00ff8a" } }) const searchExtensions = [ searchState, Prec.low(searchHighlighter), baseTheme ] search-6.5.6/src/selection-match.ts000066400000000000000000000154621456067625700172450ustar00rootroot00000000000000import {EditorView, ViewPlugin, Decoration, DecorationSet, ViewUpdate} from "@codemirror/view" import {Facet, combineConfig, Extension, CharCategory, EditorSelection, EditorState, StateCommand} from "@codemirror/state" import {SearchCursor} from "./cursor" type HighlightOptions = { /// Determines whether, when nothing is selected, the word around /// the cursor is matched instead. Defaults to false. highlightWordAroundCursor?: boolean, /// The minimum length of the selection before it is highlighted. /// Defaults to 1 (always highlight non-cursor selections). minSelectionLength?: number, /// The amount of matches (in the viewport) at which to disable /// highlighting. Defaults to 100. maxMatches?: number /// Whether to only highlight whole words. wholeWords?: boolean } const defaultHighlightOptions = { highlightWordAroundCursor: false, minSelectionLength: 1, maxMatches: 100, wholeWords: false } const highlightConfig = Facet.define>({ combine(options: readonly HighlightOptions[]) { return combineConfig(options, defaultHighlightOptions, { highlightWordAroundCursor: (a, b) => a || b, minSelectionLength: Math.min, maxMatches: Math.min }) } }) /// This extension highlights text that matches the selection. It uses /// the `"cm-selectionMatch"` class for the highlighting. When /// `highlightWordAroundCursor` is enabled, the word at the cursor /// itself will be highlighted with `"cm-selectionMatch-main"`. export function highlightSelectionMatches(options?: HighlightOptions): Extension { let ext = [defaultTheme, matchHighlighter] if (options) ext.push(highlightConfig.of(options)) return ext } const matchDeco = Decoration.mark({class: "cm-selectionMatch"}) const mainMatchDeco = Decoration.mark({class: "cm-selectionMatch cm-selectionMatch-main"}) // Whether the characters directly outside the given positions are non-word characters function insideWordBoundaries (check: (char: string) => CharCategory, state: EditorState, from: number, to: number): boolean { return (from == 0 || check(state.sliceDoc(from - 1, from)) != CharCategory.Word) && (to == state.doc.length || check(state.sliceDoc(to, to + 1)) != CharCategory.Word) } // Whether the characters directly at the given positions are word characters function insideWord (check: (char: string) => CharCategory, state: EditorState, from: number, to: number): boolean { return check(state.sliceDoc(from, from + 1)) == CharCategory.Word && check(state.sliceDoc(to - 1, to)) == CharCategory.Word } const matchHighlighter = ViewPlugin.fromClass(class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = this.getDeco(view) } update(update: ViewUpdate) { if (update.selectionSet || update.docChanged || update.viewportChanged) this.decorations = this.getDeco(update.view) } getDeco(view: EditorView) { let conf = view.state.facet(highlightConfig) let {state} = view, sel = state.selection if (sel.ranges.length > 1) return Decoration.none let range = sel.main, query, check = null if (range.empty) { if (!conf.highlightWordAroundCursor) return Decoration.none let word = state.wordAt(range.head) if (!word) return Decoration.none check = state.charCategorizer(range.head) query = state.sliceDoc(word.from, word.to) } else { let len = range.to - range.from if (len < conf.minSelectionLength || len > 200) return Decoration.none if (conf.wholeWords) { query = state.sliceDoc(range.from, range.to) // TODO: allow and include leading/trailing space? check = state.charCategorizer(range.head) if (!(insideWordBoundaries(check, state, range.from, range.to) && insideWord(check, state, range.from, range.to))) return Decoration.none } else { query = state.sliceDoc(range.from, range.to) if (!query) return Decoration.none } } let deco = [] for (let part of view.visibleRanges) { let cursor = new SearchCursor(state.doc, query, part.from, part.to) while (!cursor.next().done) { let {from, to} = cursor.value if (!check || insideWordBoundaries(check, state, from, to)) { if (range.empty && from <= range.from && to >= range.to) deco.push(mainMatchDeco.range(from, to)) else if (from >= range.to || to <= range.from) deco.push(matchDeco.range(from, to)) if (deco.length > conf.maxMatches) return Decoration.none } } } return Decoration.set(deco) } }, { decorations: v => v.decorations }) const defaultTheme = EditorView.baseTheme({ ".cm-selectionMatch": { backgroundColor: "#99ff7780" }, ".cm-searchMatch .cm-selectionMatch": {backgroundColor: "transparent"} }) // Select the words around the cursors. const selectWord: StateCommand = ({state, dispatch}) => { let {selection} = state let newSel = EditorSelection.create(selection.ranges.map( range => state.wordAt(range.head) || EditorSelection.cursor(range.head) ), selection.mainIndex) if (newSel.eq(selection)) return false dispatch(state.update({selection: newSel})) return true } // Find next occurrence of query relative to last cursor. Wrap around // the document if there are no more matches. function findNextOccurrence(state: EditorState, query: string) { let {main, ranges} = state.selection let word = state.wordAt(main.head), fullWord = word && word.from == main.from && word.to == main.to for (let cycled = false, cursor = new SearchCursor(state.doc, query, ranges[ranges.length - 1].to);;) { cursor.next() if (cursor.done) { if (cycled) return null cursor = new SearchCursor(state.doc, query, 0, Math.max(0, ranges[ranges.length - 1].from - 1)) cycled = true } else { if (cycled && ranges.some(r => r.from == cursor.value.from)) continue if (fullWord) { let word = state.wordAt(cursor.value.from) if (!word || word.from != cursor.value.from || word.to != cursor.value.to) continue } return cursor.value } } } /// Select next occurrence of the current selection. Expand selection /// to the surrounding word when the selection is empty. export const selectNextOccurrence: StateCommand = ({state, dispatch}) => { let {ranges} = state.selection if (ranges.some(sel => sel.from === sel.to)) return selectWord({state, dispatch}) let searchedText = state.sliceDoc(ranges[0].from, ranges[0].to) if (state.selection.ranges.some(r => state.sliceDoc(r.from, r.to) != searchedText)) return false let range = findNextOccurrence(state, searchedText) if (!range) return false dispatch(state.update({ selection: state.selection.addRange(EditorSelection.range(range.from, range.to), false), effects: EditorView.scrollIntoView(range.to) })) return true } search-6.5.6/test/000077500000000000000000000000001456067625700137765ustar00rootroot00000000000000search-6.5.6/test/test-cursor.ts000066400000000000000000000112561456067625700166450ustar00rootroot00000000000000import {SearchCursor, RegExpCursor} from "@codemirror/search" import {Text} from "@codemirror/state" import ist from "ist" function testMatches(cursor: SearchCursor | RegExpCursor, expected: [number, number][]) { let matches = [] while (!cursor.next().done) matches.push([cursor.value.from, cursor.value.to]) ist(JSON.stringify(matches), JSON.stringify(expected)) } describe("SearchCursor", () => { it("finds all matches in a simple string", () => { testMatches(new SearchCursor(Text.of(["one two one two one"]), "one"), [[0, 3], [8, 11], [16, 19]]) }) it("finds only matches in the given region", () => { testMatches(new SearchCursor(Text.of(["one two one two one"]), "one", 2, 17), [[8, 11]]) }) it("can cross lines", () => { testMatches(new SearchCursor(Text.of(["one two", "one two", "one"]), "one"), [[0, 3], [8, 11], [16, 19]]) }) it("can normalize case", () => { testMatches(new SearchCursor(Text.of(["ONE two oNe two one"]), "one", 0, 19, s => s.toLowerCase()), [[0, 3], [8, 11], [16, 19]]) }) it("doesn't get confused by expanding transforms", () => { testMatches(new SearchCursor(Text.of(["Auf die Straße"]), "straße", 0, 14, s => s.toUpperCase()), [[8, 14]]) }) it("normalizes composed chars", () => { testMatches(new SearchCursor(Text.of(["héé"]), "héé"), // First one is composed, second decomposed [[0, 3]]) testMatches(new SearchCursor(Text.of(["héé"]), "héé"), // First one is decomposed, second composed [[0, 5]]) }) it("can match across lines", () => { testMatches(new SearchCursor(Text.of(["one two", "three four"]), "two\nthree"), [[4, 13]]) }) it("can search an empty document", () => { testMatches(new SearchCursor(Text.empty, "aaaa"), []) }) it("doesn't include overlapping results", () => { testMatches(new SearchCursor(Text.of(["fofofofo"]), "fofo"), [[0, 4], [4, 8]]) }) it("includes overlapping results with nextOverlapping", () => { let cursor = new SearchCursor(Text.of(["fofofofo"]), "fofo") let matches = [] while (!cursor.nextOverlapping().done) matches.push([cursor.value.from, cursor.value.to]) ist(JSON.stringify(matches), "[[0,4],[2,6],[4,8]]") }) it("will not match partial normalized content", () => { testMatches(new SearchCursor(Text.of(["´"]), " "), []) }) it("produces the correct range for astral chars that get normalized to non-astral", () => { testMatches(new SearchCursor(Text.of(["𝜎"]), "𝜎"), [[0, 2]]) }) }) describe("RegExpCursor", () => { it("finds all matches in a simple string", () => { testMatches(new RegExpCursor(Text.of(["one two one two one"]), "one"), [[0, 3], [8, 11], [16, 19]]) }) it("matches by-line", () => { testMatches(new RegExpCursor(Text.of(["one two", "three four five", "six"]), "^\\w+|\\w+$"), [[0, 3], [4, 7], [8, 13], [19, 23], [24, 27]]) }) it("handles empty lines", () => { testMatches(new RegExpCursor(Text.of(["one", "", "two"]), ".*"), [[0, 3], [4, 4], [5, 8]]) }) it("handles empty documents", () => { testMatches(new RegExpCursor(Text.empty, ".*"), [[0, 0]]) testMatches(new RegExpCursor(Text.empty, "okay"), []) }) it("properly cuts off long matches", () => { testMatches(new RegExpCursor(Text.of(["abcdefghi"]), ".*", {}, 3, 6), [[3, 6]]) }) it("can match case-insensitively", () => { testMatches(new RegExpCursor(Text.of(["abcdefghi"]), "DEF", {ignoreCase: true}), [[3, 6]]) }) it("matches across lines", () => { testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\nd"), [[2, 5]]) }) it("detects multi-line regexps", () => { testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\\sd"), [[2, 5]]) testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\\Wd"), [[2, 5]]) testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\\Dd"), [[2, 5]]) testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c[^x]d"), [[2, 5]]) }) it("can match a large document", () => { let line = "1234567890".repeat(10) let doc = Text.of(new Array(100).fill(line)) let cur = new RegExpCursor(doc, "[^]*").next() ist(!cur.done) ist(cur.value.from, 0) ist(cur.value.to, doc.length) ist(cur.value.match[0].length, doc.length) }) it("will match line starts properly in multiline mode", () => { testMatches(new RegExpCursor(Text.of(["x", "a1111", "111a1111"]), "^a(1|\\s)*"), [[2, 11]]) }) it("will match line ends properly in multiline mode", () => { testMatches(new RegExpCursor(Text.of(["x", "111p111", "1111p"]), "(1|\\s)*p$"), [[6, 15]]) }) }) search-6.5.6/test/test-query.ts000066400000000000000000000040161456067625700164710ustar00rootroot00000000000000import {SearchQuery} from "@codemirror/search" import {Text} from "@codemirror/state" import ist from "ist" function test(query: SearchQuery, doc: string) { let matches = [], m while (m = /\[([^]*?)\]/.exec(doc)) { matches.push([m.index, m.index + m[1].length]) doc = doc.slice(0, m.index) + m[1] + doc.slice(m.index + m[0].length) } let text = Text.of(doc.split("\n")) let cursor = query.getCursor(text), found = [] for (let v; !(v = cursor.next()).done;) found.push([v.value.from, v.value.to]) ist(JSON.stringify(found), JSON.stringify(matches)) } describe("SearchQuery", () => { it("can match plain strings", () => { test(new SearchQuery({search: "abc"}), "[abc] flakdj a[abc] aabbcc") }) it("skips overlapping matches", () => { test(new SearchQuery({search: "aba"}), "[aba]b[aba].") }) it("can match case-insensitive strings", () => { test(new SearchQuery({search: "abC", caseSensitive: false}), "[aBc] flakdj a[ABC]") }) it("can match across lines", () => { test(new SearchQuery({search: "a\\nb"}), "a [a\nb] b") }) it("can match across multiple lines", () => { test(new SearchQuery({search: "a\\nb\\nc\\nd"}), "a [a\nb\nc\nd] e") }) it("can match literally", () => { test(new SearchQuery({search: "a\\nb", literal: true}), "a\nb [a\\nb]") }) it("can match by word", () => { test(new SearchQuery({search: "hello", wholeWord: true}), "[hello] hellothere [hello]\nello ahello ohellop") }) it("doesn't match non-words by word", () => { test(new SearchQuery({search: "^_^", wholeWord: true}), "x[^_^]y [^_^]") }) it("can match regular expressions", () => { test(new SearchQuery({search: "a..b", regexp: true}), "[appb] apb") }) it("can match case-insensitive regular expressions", () => { test(new SearchQuery({search: "a..b", regexp: true, caseSensitive: false}), "[Appb] Apb") }) it("can match regular expressions by word", () => { test(new SearchQuery({search: "a..", regexp: true, wholeWord: true}), "[aap] baap aapje [a--]w") }) }) search-6.5.6/test/test-selection-match.ts000066400000000000000000000040161456067625700204030ustar00rootroot00000000000000import {selectNextOccurrence} from "@codemirror/search" import {EditorState, EditorSelection, SelectionRange} from "@codemirror/state" import ist from "ist" function mkState(doc: string) { let ranges: SelectionRange[] = [], off = 0 doc = doc.replace(/\||<([^]*?)>/g, (_m, content, index) => { ranges.push(EditorSelection.range(index - off, index - off + (content ? content.length : 0))) off += (content ? 2 : 1) return content || "" }) return EditorState.create({ doc, selection: EditorSelection.create(ranges, 0), extensions: EditorState.allowMultipleSelections.of(true) }) } function stateStr(state: EditorState) { let doc = state.doc.toString() for (let i = state.selection.ranges.length - 1; i >= 0; i--) { let range = state.selection.ranges[i] if (range.empty) doc = doc.slice(0, range.from) + "|" + doc.slice(range.from) else doc = doc.slice(0, range.from) + "<" + doc.slice(range.from, range.to) + ">" + doc.slice(range.to) } return doc } describe("selectNextOccurrence", () => { function test(doc: string, expected: string) { let state = mkState(doc) selectNextOccurrence({state, dispatch: tr => { state = tr.state }}) ist(stateStr(state), expected) } it("expands to the surrounding word", () => { test('one| two', ' two') test('|one two', ' two') test('o|ne two', ' two') test('one |two', 'one ') }) it("selects the next occurrence", () => { test(" one one", " one") test(" two one", " two ") test("one one", "one ") test("one one", "one ") test("one ", " ") test(" ", " ") }) it("matches full words", () => { test(" two onetwo one", " two onetwo ") }) it("matches subwords if a subword is selected", () => { test("two onethree", "two three") test("two onethree", "two three") }) })