pax_global_header00006660000000000000000000000064145310627470014523gustar00rootroot0000000000000052 comment=ffaa367319954cef49da50f4f42e60b876e4cc78 autocomplete-6.11.1/000077500000000000000000000000001453106274700143125ustar00rootroot00000000000000autocomplete-6.11.1/.github/000077500000000000000000000000001453106274700156525ustar00rootroot00000000000000autocomplete-6.11.1/.github/workflows/000077500000000000000000000000001453106274700177075ustar00rootroot00000000000000autocomplete-6.11.1/.github/workflows/dispatch.yml000066400000000000000000000006371453106274700222370ustar00rootroot00000000000000name: 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 autocomplete-6.11.1/.gitignore000066400000000000000000000001271453106274700163020ustar00rootroot00000000000000/node_modules package-lock.json /dist /test/*.js /test/*.d.ts /test/*.d.ts.map .tern-* autocomplete-6.11.1/.npmignore000066400000000000000000000001001453106274700163000ustar00rootroot00000000000000/src /test /node_modules .tern-* rollup.config.js tsconfig.json autocomplete-6.11.1/CHANGELOG.md000066400000000000000000000322441453106274700161300ustar00rootroot00000000000000## 6.11.1 (2023-11-27) ### Bug fixes Fix a bug that caused typing over closed brackets after pressing enter to still not work in many situations. ## 6.11.0 (2023-11-09) ### Bug fixes Fix an issue that would prevent typing over closed brackets after starting a new line with enter. ### New features Additional elements rendered in completion options with `addToOptions` are now given access to the editor view. ## 6.10.2 (2023-10-13) ### Bug fixes Fix a bug that caused `updateSyncTime` to always delay the initial population of the tooltip. ## 6.10.1 (2023-10-11) ### Bug fixes Fix a bug where picking a selection with the mouse could use the wrong completion if the completion list was updated after being opened. ## 6.10.0 (2023-10-11) ### New features The new autocompletion configuration option `updateSyncTime` allows control over how long fast sources are held back waiting for slower completion sources. ## 6.9.2 (2023-10-06) ### Bug fixes Fix a bug in `completeAnyWord` that could cause it to generate invalid regular expressions and crash. ## 6.9.1 (2023-09-14) ### Bug fixes Make sure the cursor is scrolled into view after inserting completion text. Make sure scrolling completions into view doesn't get confused when the tooltip is scaled. ## 6.9.0 (2023-07-18) ### New features Completions may now provide a `displayLabel` property that overrides the way they are displayed in the completion list. ## 6.8.1 (2023-06-23) ### Bug fixes `acceptCompletion` now returns false (allowing other handlers to take effect) when the completion popup is open but disabled. ## 6.8.0 (2023-06-12) ### New features The result of `Completion.info` may now include a `destroy` method that will be called when the tooltip is removed. ## 6.7.1 (2023-05-13) ### Bug fixes Fix a bug that cause incorrect ordering of completions when some results covered input text and others didn't. ## 6.7.0 (2023-05-11) ### New features The new `hasNextSnippetField` and `hasPrevSnippetField` functions can be used to figure out if the snippet-field-motion commands apply to a given state. ## 6.6.1 (2023-05-03) ### Bug fixes Fix a bug that made the editor use the completion's original position, rather than its current position, when changes happened in the document while a result was active. ## 6.6.0 (2023-04-27) ### Bug fixes Fix a bug in `insertCompletionText` that caused it to replace the wrong range when a result set's `to` fell after the cursor. ### New features Functions returned by `snippet` can now be called without a completion object. ## 6.5.1 (2023-04-13) ### Bug fixes Keep completions open when interaction with an info tooltip moves focus out of the editor. ## 6.5.0 (2023-04-13) ### Bug fixes When `closeBrackets` skips a bracket, it now generates a change that overwrites the bracket. Replace the entire selected range when picking a completion with a non-cursor selection active. ### New features Completions can now provide a `section` field that is used to group them into sections. The new `positionInfo` option can be used to provide custom logic for positioning the info tooltips. ## 6.4.2 (2023-02-17) ### Bug fixes Fix a bug where the apply method created by `snippet` didn't add a `pickedCompletion` annotation to the transactions it created. ## 6.4.1 (2023-02-14) ### Bug fixes Don't consider node names in trees that aren't the same language as the one at the completion position in `ifIn` and `ifNotIn`. Make sure completions that exactly match the input get a higher score than those that don't (so that even if the latter has a score boost, it ends up lower in the list). ## 6.4.0 (2022-12-14) ### Bug fixes Fix an issue where the extension would sometimes try to draw a disabled dialog at an outdated position, leading to plugin crashes. ### New features A `tooltipClass` option to autocompletion can now be used to add additional CSS classes to the completion tooltip. ## 6.3.4 (2022-11-24) ### Bug fixes Fix an issue where completion lists could end up being higher than the tooltip they were in. ## 6.3.3 (2022-11-18) ### Bug fixes Set an explicit `box-sizing` style on completion icons so CSS resets don't mess them up. Allow closing braces in templates to be escaped with a backslash. ## 6.3.2 (2022-11-15) ### Bug fixes Fix a regression that could cause the completion dialog to stick around when it should be hidden. ## 6.3.1 (2022-11-14) ### Bug fixes Fix a regression where transactions for picking a completion (without custom `apply` method) no longer had the `pickedCompletion` annotation. Reduce flickering for completion sources without `validFor` info by temporarily showing a disabled tooltip while the completion updates. Make sure completion info tooltips are kept within the space provided by the `tooltipSpace` option. ## 6.3.0 (2022-09-22) ### New features Close bracket configuration now supports a `stringPrefixes` property that can be used to allow autoclosing of prefixed strings. ## 6.2.0 (2022-09-13) ### New features Autocompletion now takes an `interactionDelay` option that can be used to control the delay between the time where completion opens and the time where commands like `acceptCompletion` affect it. ## 6.1.1 (2022-09-08) ### Bug fixes Fix a bug that prevented transactions produced by `deleteBracketPair` from being marked as deletion user events. Improve positioning of completion info tooltips so they are less likely to stick out of the screen on small displays. ## 6.1.0 (2022-07-19) ### New features You can now provide a `compareCompletions` option to autocompletion to influence the way completions with the same match score are sorted. The `selectOnOpen` option to autocompletion can be used to require explicitly selecting a completion option before `acceptCompletion` does anything. ## 6.0.4 (2022-07-07) ### Bug fixes Remove a leftover `console.log` in bracket closing code. ## 6.0.3 (2022-07-04) ### Bug fixes Fix a bug that caused `closeBrackets` to not close quotes when at the end of a syntactic construct that starts with a similar quote. ## 6.0.2 (2022-06-15) ### Bug fixes Declare package dependencies as peer dependencies as an attempt to avoid duplicated package issues. ## 6.0.1 (2022-06-09) ### Bug fixes Support escaping `${` or `#{` in snippets. ## 6.0.0 (2022-06-08) ### Bug fixes Scroll the cursor into view when inserting a snippet. ## 0.20.3 (2022-05-30) ### Bug fixes Add an aria-label to the completion listbox. Fix a regression that caused transactions generated for completion to not have a `userEvent` annotation. ## 0.20.2 (2022-05-24) ### New features The package now exports an `insertCompletionText` helper that implements the default behavior for applying a completion. ## 0.20.1 (2022-05-16) ### New features The new `closeOnBlur` option determines whether the completion tooltip is closed when the editor loses focus. `CompletionResult` objects with `filter: false` may now have a `getMatch` property that determines the matched range in the options. ## 0.20.0 (2022-04-20) ### Breaking changes `CompletionResult.span` has been renamed to `validFor`, and may now hold a function as well as a regular expression. ### Bug fixes Remove code that dropped any options beyond the 300th one when matching and sorting option lists. Completion will now apply to all cursors when there are multiple cursors. ### New features `CompletionResult.update` can now be used to implement quick autocompletion updates in a synchronous way. The @codemirror/closebrackets package was merged into this one. ## 0.19.15 (2022-03-23) ### New features The `selectedCompletionIndex` function tells you the position of the currently selected completion. The new `setSelectionCompletion` function creates a state effect that moves the selected completion to a given index. A completion's `info` method may now return null to indicate that no further info is available. ## 0.19.14 (2022-03-10) ### Bug fixes Make the ARIA attributes added to the editor during autocompletion spec-compliant. ## 0.19.13 (2022-02-18) ### Bug fixes Fix an issue where the completion tooltip stayed open if it was explicitly opened and the user backspaced past its start. Stop snippet filling when a change happens across one of the snippet fields' boundaries. ## 0.19.12 (2022-01-11) ### Bug fixes Fix completion navigation with PageUp/Down when the completion tooltip isn't part of the view DOM. ## 0.19.11 (2022-01-11) ### Bug fixes Fix a bug that caused page up/down to only move the selection by two options in the completion tooltip. ## 0.19.10 (2022-01-05) ### Bug fixes Make sure the info tooltip is hidden when the selected option is scrolled out of view. Fix a bug in the completion ranking that would sometimes give options that match the input by word start chars higher scores than appropriate. Options are now sorted (ascending) by length when their match score is otherwise identical. ## 0.19.9 (2021-11-26) ### Bug fixes Fix an issue where info tooltips would be visible in an inappropriate position when there was no room to place them properly. ## 0.19.8 (2021-11-17) ### Bug fixes Give the completion tooltip a minimal width, and show ellipsis when completions overflow the tooltip width. ### New features `autocompletion` now accepts an `aboveCursor` option to make the completion tooltip show up above the cursor. ## 0.19.7 (2021-11-16) ### Bug fixes Make option deduplication less aggressive, so that options with different `type` or `apply` fields don't get merged. ## 0.19.6 (2021-11-12) ### Bug fixes Fix an issue where parsing a snippet with a field that was labeled only by a number crashed. ## 0.19.5 (2021-11-09) ### Bug fixes Make sure info tooltips don't stick out of the bottom of the page. ### New features The package exports a new function `selectedCompletion`, which can be used to find out which completion is currently selected. Transactions created by picking a completion now have an annotation (`pickedCompletion`) holding the original completion. ## 0.19.4 (2021-10-24) ### Bug fixes Don't rely on the platform's highlight colors for the active completion, since those are inconsistent and may not be appropriate for the theme. Fix incorrect match underline for some kinds of matched completions. ## 0.19.3 (2021-08-31) ### Bug fixes Improve the sorting of completions by using `localeCompare`. Fix reading of autocompletions in NVDA screen reader. ### New features The new `icons` option can be used to turn off icons in the completion list. The `optionClass` option can now be used to add CSS classes to the options in the completion list. It is now possible to inject additional content into rendered completion options with the `addToOptions` configuration option. ## 0.19.2 (2021-08-25) ### Bug fixes Fix an issue where `completeAnyWord` would return results when there was no query and `explicit` was false. ## 0.19.1 (2021-08-11) ### Bug fixes Fix incorrect versions for @lezer dependencies. ## 0.19.0 (2021-08-11) ### Breaking changes Update dependencies to 0.19.0 ## 0.18.8 (2021-06-30) ### New features Add an `ifIn` helper function that constrains a completion source to only fire when in a given syntax node. Add support for unfiltered completions A completion result can now set a `filter: false` property to disable filtering and sorting of completions, when it already did so itself. ## 0.18.7 (2021-06-14) ### Bug fixes Don't treat continued completions when typing after an explicit completion as explicit. ## 0.18.6 (2021-06-03) ### Bug fixes Adding or reconfiguring completion sources will now cause them to be activated right away if a completion was active. ### New features You can now specify multiple types in `Completion.type` by separating them by spaces. Small doc comment tweak for Completion.type ## 0.18.5 (2021-04-23) ### Bug fixes Fix a regression where snippet field selection didn't work with @codemirror/state 0.18.6. Fix a bug where snippet fields with different position numbers were inappropriately merged. ## 0.18.4 (2021-04-20) ### Bug fixes Fix a crash in Safari when moving the selection during composition. ## 0.18.3 (2021-03-15) ### Bug fixes Adjust to updated @codemirror/tooltip interface. ## 0.18.2 (2021-03-14) ### Bug fixes Fix unintended ES2020 output (the package contains ES6 code again). ## 0.18.1 (2021-03-11) ### Bug fixes Stop active completion when all sources resolve without producing any matches. ### New features `Completion.info` may now return a promise. ## 0.18.0 (2021-03-03) ### Bug fixes Only preserve selected option across updates when it isn't the first option. ## 0.17.4 (2021-01-18) ### Bug fixes Fix a styling issue where the selection had become invisible inside snippet fields (when using `drawSelection`). ### New features Snippet fields can now be selected with the pointing device (so that they are usable on touch devices). ## 0.17.3 (2021-01-18) ### Bug fixes Fix a bug where uppercase completions would be incorrectly matched against the typed input. ## 0.17.2 (2021-01-12) ### Bug fixes Don't bind Cmd-Space on macOS, since that already has a system default binding. Use Ctrl-Space for autocompletion. ## 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. autocomplete-6.11.1/LICENSE000066400000000000000000000021361453106274700153210ustar00rootroot00000000000000MIT 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. autocomplete-6.11.1/README.md000066400000000000000000000020241453106274700155670ustar00rootroot00000000000000# @codemirror/autocomplete [![NPM version](https://img.shields.io/npm/v/@codemirror/autocomplete.svg)](https://www.npmjs.org/package/@codemirror/autocomplete) [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#autocomplete) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/autocomplete/blob/main/CHANGELOG.md) ] This package implements autocompletion 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/autocomplete/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. autocomplete-6.11.1/package.json000066400000000000000000000021571453106274700166050ustar00rootroot00000000000000{ "name": "@codemirror/autocomplete", "version": "6.11.1", "description": "Autocompletion for the CodeMirror code editor", "scripts": { "test": "cm-runtests", "prepare": "cm-buildhelper src/index.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/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" }, "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0" }, "devDependencies": { "@codemirror/buildhelper": "^1.0.0" }, "repository": { "type": "git", "url": "https://github.com/codemirror/autocomplete.git" } } autocomplete-6.11.1/src/000077500000000000000000000000001453106274700151015ustar00rootroot00000000000000autocomplete-6.11.1/src/README.md000066400000000000000000000013451453106274700163630ustar00rootroot00000000000000@Completion @CompletionInfo @CompletionSection @autocompletion @completionStatus @currentCompletions @selectedCompletion @selectedCompletionIndex @setSelectedCompletion @pickedCompletion ### Sources @CompletionContext @CompletionResult @CompletionSource @completeFromList @ifIn @ifNotIn @completeAnyWord @insertCompletionText ### Commands @startCompletion @closeCompletion @acceptCompletion @moveCompletionSelection @completionKeymap ### Snippets @snippet @snippetCompletion @nextSnippetField @hasNextSnippetField @prevSnippetField @hasPrevSnippetField @clearSnippet @snippetKeymap ### Automatic Bracket Closing @CloseBracketConfig @closeBrackets @closeBracketsKeymap @deleteBracketPair @insertBracket autocomplete-6.11.1/src/closebrackets.ts000066400000000000000000000262131453106274700203010ustar00rootroot00000000000000import {EditorView, KeyBinding} from "@codemirror/view" import {EditorState, EditorSelection, Transaction, Extension, StateCommand, StateField, StateEffect, MapMode, CharCategory, Text, codePointAt, fromCodePoint, codePointSize, RangeSet, RangeValue} from "@codemirror/state" import {syntaxTree} from "@codemirror/language" /// Configures bracket closing behavior for a syntax (via /// [language data](#state.EditorState.languageDataAt)) using the `"closeBrackets"` /// identifier. export interface CloseBracketConfig { /// The opening brackets to close. Defaults to `["(", "[", "{", "'", /// '"']`. Brackets may be single characters or a triple of quotes /// (as in `"''''"`). brackets?: string[] /// Characters in front of which newly opened brackets are /// automatically closed. Closing always happens in front of /// whitespace. Defaults to `")]}:;>"`. before?: string /// When determining whether a given node may be a string, recognize /// these prefixes before the opening quote. stringPrefixes?: string[] } const defaults: Required = { brackets: ["(", "[", "{", "'", '"'], before: ")]}:;>", stringPrefixes: [] } const closeBracketEffect = StateEffect.define({ map(value, mapping) { let mapped = mapping.mapPos(value, -1, MapMode.TrackAfter) return mapped == null ? undefined : mapped } }) const closedBracket = new class extends RangeValue {} closedBracket.startSide = 1; closedBracket.endSide = -1 const bracketState = StateField.define>({ create() { return RangeSet.empty }, update(value, tr) { value = value.map(tr.changes) if (tr.selection) { let line = tr.state.doc.lineAt(tr.selection.main.head) value = value.update({filter: from => from >= line.from && from <= line.to}) } for (let effect of tr.effects) if (effect.is(closeBracketEffect)) value = value.update({add: [closedBracket.range(effect.value, effect.value + 1)]}) return value } }) /// Extension to enable bracket-closing behavior. When a closeable /// bracket is typed, its closing bracket is immediately inserted /// after the cursor. When closing a bracket directly in front of a /// closing bracket inserted by the extension, the cursor moves over /// that bracket. export function closeBrackets(): Extension { return [inputHandler, bracketState] } const definedClosing = "()[]{}<>" function closing(ch: number) { for (let i = 0; i < definedClosing.length; i += 2) if (definedClosing.charCodeAt(i) == ch) return definedClosing.charAt(i + 1) return fromCodePoint(ch < 128 ? ch : ch + 1) } function config(state: EditorState, pos: number) { return state.languageDataAt("closeBrackets", pos)[0] || defaults } const android = typeof navigator == "object" && /Android\b/.test(navigator.userAgent) const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { if ((android ? view.composing : view.compositionStarted) || view.state.readOnly) return false let sel = view.state.selection.main if (insert.length > 2 || insert.length == 2 && codePointSize(codePointAt(insert, 0)) == 1 || from != sel.from || to != sel.to) return false let tr = insertBracket(view.state, insert) if (!tr) return false view.dispatch(tr) return true }) /// Command that implements deleting a pair of matching brackets when /// the cursor is between them. export const deleteBracketPair: StateCommand = ({state, dispatch}) => { if (state.readOnly) return false let conf = config(state, state.selection.main.head) let tokens = conf.brackets || defaults.brackets let dont = null, changes = state.changeByRange(range => { if (range.empty) { let before = prevChar(state.doc, range.head) for (let token of tokens) { if (token == before && nextChar(state.doc, range.head) == closing(codePointAt(token, 0))) return {changes: {from: range.head - token.length, to: range.head + token.length}, range: EditorSelection.cursor(range.head - token.length)} } } return {range: dont = range} }) if (!dont) dispatch(state.update(changes, {scrollIntoView: true, userEvent: "delete.backward"})) return !dont } /// Close-brackets related key bindings. Binds Backspace to /// [`deleteBracketPair`](#autocomplete.deleteBracketPair). export const closeBracketsKeymap: readonly KeyBinding[] = [ {key: "Backspace", run: deleteBracketPair} ] /// Implements the extension's behavior on text insertion. If the /// given string counts as a bracket in the language around the /// selection, and replacing the selection with it requires custom /// behavior (inserting a closing version or skipping past a /// previously-closed bracket), this function returns a transaction /// representing that custom behavior. (You only need this if you want /// to programmatically insert brackets—the /// [`closeBrackets`](#autocomplete.closeBrackets) extension will /// take care of running this for user input.) export function insertBracket(state: EditorState, bracket: string): Transaction | null { let conf = config(state, state.selection.main.head) let tokens = conf.brackets || defaults.brackets for (let tok of tokens) { let closed = closing(codePointAt(tok, 0)) if (bracket == tok) return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok + tok) > -1, conf) : handleOpen(state, tok, closed, conf.before || defaults.before) if (bracket == closed && closedBracketAt(state, state.selection.main.from)) return handleClose(state, tok, closed) } return null } function closedBracketAt(state: EditorState, pos: number) { let found = false state.field(bracketState).between(0, state.doc.length, from => { if (from == pos) found = true }) return found } function nextChar(doc: Text, pos: number) { let next = doc.sliceString(pos, pos + 2) return next.slice(0, codePointSize(codePointAt(next, 0))) } function prevChar(doc: Text, pos: number) { let prev = doc.sliceString(pos - 2, pos) return codePointSize(codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1) } function handleOpen(state: EditorState, open: string, close: string, closeBefore: string) { let dont = null, changes = state.changeByRange(range => { if (!range.empty) return {changes: [{insert: open, from: range.from}, {insert: close, from: range.to}], effects: closeBracketEffect.of(range.to + open.length), range: EditorSelection.range(range.anchor + open.length, range.head + open.length)} let next = nextChar(state.doc, range.head) if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) return {changes: {insert: open + close, from: range.head}, effects: closeBracketEffect.of(range.head + open.length), range: EditorSelection.cursor(range.head + open.length)} return {range: dont = range} }) return dont ? null : state.update(changes, { scrollIntoView: true, userEvent: "input.type" }) } function handleClose(state: EditorState, _open: string, close: string) { let dont = null, changes = state.changeByRange(range => { if (range.empty && nextChar(state.doc, range.head) == close) return {changes: {from: range.head, to: range.head + close.length, insert: close}, range: EditorSelection.cursor(range.head + close.length)} return dont = {range} }) return dont ? null : state.update(changes, { scrollIntoView: true, userEvent: "input.type" }) } // Handles cases where the open and close token are the same, and // possibly triple quotes (as in `"""abc"""`-style quoting). function handleSame(state: EditorState, token: string, allowTriple: boolean, config: CloseBracketConfig) { let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes let dont = null, changes = state.changeByRange(range => { if (!range.empty) return {changes: [{insert: token, from: range.from}, {insert: token, from: range.to}], effects: closeBracketEffect.of(range.to + token.length), range: EditorSelection.range(range.anchor + token.length, range.head + token.length)} let pos = range.head, next = nextChar(state.doc, pos), start if (next == token) { if (nodeStart(state, pos)) { return {changes: {insert: token + token, from: pos}, effects: closeBracketEffect.of(pos + token.length), range: EditorSelection.cursor(pos + token.length)} } else if (closedBracketAt(state, pos)) { let isTriple = allowTriple && state.sliceDoc(pos, pos + token.length * 3) == token + token + token let content = isTriple ? token + token + token : token return {changes: {from: pos, to: pos + content.length, insert: content}, range: EditorSelection.cursor(pos + content.length)} } } else if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token && (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 && nodeStart(state, start)) { return {changes: {insert: token + token + token + token, from: pos}, effects: closeBracketEffect.of(pos + token.length), range: EditorSelection.cursor(pos + token.length)} } else if (state.charCategorizer(pos)(next) != CharCategory.Word) { if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes)) return {changes: {insert: token + token, from: pos}, effects: closeBracketEffect.of(pos + token.length), range: EditorSelection.cursor(pos + token.length)} } return {range: dont = range} }) return dont ? null : state.update(changes, { scrollIntoView: true, userEvent: "input.type" }) } function nodeStart(state: EditorState, pos: number) { let tree = syntaxTree(state).resolveInner(pos + 1) return tree.parent && tree.from == pos } function probablyInString(state: EditorState, pos: number, quoteToken: string, prefixes: readonly string[]) { let node = syntaxTree(state).resolveInner(pos, -1) let maxPrefix = prefixes.reduce((m, p) => Math.max(m, p.length), 0) for (let i = 0; i < 5; i++) { let start = state.sliceDoc(node.from, Math.min(node.to, node.from + quoteToken.length + maxPrefix)) let quotePos = start.indexOf(quoteToken) if (!quotePos || quotePos > -1 && prefixes.indexOf(start.slice(0, quotePos)) > -1) { let first = node.firstChild while (first && first.from == node.from && first.to - first.from > quoteToken.length + quotePos) { if (state.sliceDoc(first.to - quoteToken.length, first.to) == quoteToken) return false first = first.firstChild } return true } let parent = node.to == pos && node.parent if (!parent) break node = parent } return false } function canStartStringAt(state: EditorState, pos: number, prefixes: readonly string[]) { let charCat = state.charCategorizer(pos) if (charCat(state.sliceDoc(pos - 1, pos)) != CharCategory.Word) return pos for (let prefix of prefixes) { let start = pos - prefix.length if (state.sliceDoc(start, pos) == prefix && charCat(state.sliceDoc(start - 1, start)) != CharCategory.Word) return start } return -1 } autocomplete-6.11.1/src/completion.ts000066400000000000000000000322441453106274700176270ustar00rootroot00000000000000import {EditorView} from "@codemirror/view" import {EditorState, StateEffect, Annotation, EditorSelection, TransactionSpec} from "@codemirror/state" import {syntaxTree} from "@codemirror/language" import {SyntaxNode} from "@lezer/common" /// Objects type used to represent individual completions. export interface Completion { /// The label to show in the completion picker. This is what input /// is matched against to determine whether a completion matches (and /// how well it matches). label: string /// An optional override for the completion's visible label. When /// using this, matched characters will only be highlighted if you /// provide a [`getMatch`](#autocomplete.CompletionResult.getMatch) /// function. displayLabel?: string /// An optional short piece of information to show (with a different /// style) after the label. detail?: string /// Additional info to show when the completion is selected. Can be /// a plain string or a function that'll render the DOM structure to /// show when invoked. info?: string | ((completion: Completion) => CompletionInfo | Promise) /// How to apply the completion. The default is to replace it with /// its [label](#autocomplete.Completion.label). When this holds a /// string, the completion range is replaced by that string. When it /// is a function, that function is called to perform the /// completion. If it fires a transaction, it is responsible for /// adding the [`pickedCompletion`](#autocomplete.pickedCompletion) /// annotation to it. apply?: string | ((view: EditorView, completion: Completion, from: number, to: number) => void) /// The type of the completion. This is used to pick an icon to show /// for the completion. Icons are styled with a CSS class created by /// appending the type name to `"cm-completionIcon-"`. You can /// define or restyle icons by defining these selectors. The base /// library defines simple icons for `class`, `constant`, `enum`, /// `function`, `interface`, `keyword`, `method`, `namespace`, /// `property`, `text`, `type`, and `variable`. /// /// Multiple types can be provided by separating them with spaces. type?: string /// When given, should be a number from -99 to 99 that adjusts how /// this completion is ranked compared to other completions that /// match the input as well as this one. A negative number moves it /// down the list, a positive number moves it up. boost?: number /// Can be used to divide the completion list into sections. /// Completions in a given section (matched by name) will be grouped /// together, with a heading above them. Options without section /// will appear above all sections. A string value is equivalent to /// a `{name}` object. section?: string | CompletionSection } /// The type returned from /// [`Completion.info`](#autocomplete.Completion.info). May be a DOM /// node, null to indicate there is no info, or an object with an /// optional `destroy` method that cleans up the node. export type CompletionInfo = Node | null | {dom: Node, destroy?(): void} /// Object used to describe a completion /// [section](#autocomplete.Completion.section). It is recommended to /// create a shared object used by all the completions in a given /// section. export interface CompletionSection { /// The name of the section. If no `render` method is present, this /// will be displayed above the options. name: string /// An optional function that renders the section header. Since the /// headers are shown inside a list, you should make sure the /// resulting element has a `display: list-item` style. header?: (section: CompletionSection) => HTMLElement /// By default, sections are ordered alphabetically by name. To /// specify an explicit order, `rank` can be used. Sections with a /// lower rank will be shown above sections with a higher rank. rank?: number } /// An instance of this is passed to completion source functions. export class CompletionContext { /// @internal abortListeners: (() => void)[] | null = [] /// Create a new completion context. (Mostly useful for testing /// completion sources—in the editor, the extension will create /// these for you.) constructor( /// The editor state that the completion happens in. readonly state: EditorState, /// The position at which the completion is happening. readonly pos: number, /// Indicates whether completion was activated explicitly, or /// implicitly by typing. The usual way to respond to this is to /// only return completions when either there is part of a /// completable entity before the cursor, or `explicit` is true. readonly explicit: boolean ) {} /// Get the extent, content, and (if there is a token) type of the /// token before `this.pos`. tokenBefore(types: readonly string[]) { let token: SyntaxNode | null = syntaxTree(this.state).resolveInner(this.pos, -1) while (token && types.indexOf(token.name) < 0) token = token.parent return token ? {from: token.from, to: this.pos, text: this.state.sliceDoc(token.from, this.pos), type: token.type} : null } /// Get the match of the given expression directly before the /// cursor. matchBefore(expr: RegExp) { let line = this.state.doc.lineAt(this.pos) let start = Math.max(line.from, this.pos - 250) let str = line.text.slice(start - line.from, this.pos - line.from) let found = str.search(ensureAnchor(expr, false)) return found < 0 ? null : {from: start + found, to: this.pos, text: str.slice(found)} } /// Yields true when the query has been aborted. Can be useful in /// asynchronous queries to avoid doing work that will be ignored. get aborted() { return this.abortListeners == null } /// Allows you to register abort handlers, which will be called when /// the query is /// [aborted](#autocomplete.CompletionContext.aborted). addEventListener(type: "abort", listener: () => void) { if (type == "abort" && this.abortListeners) this.abortListeners.push(listener) } } function toSet(chars: {[ch: string]: true}) { let flat = Object.keys(chars).join("") let words = /\w/.test(flat) if (words) flat = flat.replace(/\w/g, "") return `[${words ? "\\w" : ""}${flat.replace(/[^\w\s]/g, "\\$&")}]` } function prefixMatch(options: readonly Completion[]) { let first = Object.create(null), rest = Object.create(null) for (let {label} of options) { first[label[0]] = true for (let i = 1; i < label.length; i++) rest[label[i]] = true } let source = toSet(first) + toSet(rest) + "*$" return [new RegExp("^" + source), new RegExp(source)] } /// Given a a fixed array of options, return an autocompleter that /// completes them. export function completeFromList(list: readonly (string | Completion)[]): CompletionSource { let options = list.map(o => typeof o == "string" ? {label: o} : o) as Completion[] let [validFor, match] = options.every(o => /^\w+$/.test(o.label)) ? [/\w*$/, /\w+$/] : prefixMatch(options) return (context: CompletionContext) => { let token = context.matchBefore(match) return token || context.explicit ? {from: token ? token.from : context.pos, options, validFor} : null } } /// Wrap the given completion source so that it will only fire when the /// cursor is in a syntax node with one of the given names. export function ifIn(nodes: readonly string[], source: CompletionSource): CompletionSource { return (context: CompletionContext) => { for (let pos: SyntaxNode | null = syntaxTree(context.state).resolveInner(context.pos, -1); pos; pos = pos.parent) { if (nodes.indexOf(pos.name) > -1) return source(context) if (pos.type.isTop) break } return null } } /// Wrap the given completion source so that it will not fire when the /// cursor is in a syntax node with one of the given names. export function ifNotIn(nodes: readonly string[], source: CompletionSource): CompletionSource { return (context: CompletionContext) => { for (let pos: SyntaxNode | null = syntaxTree(context.state).resolveInner(context.pos, -1); pos; pos = pos.parent) { if (nodes.indexOf(pos.name) > -1) return null if (pos.type.isTop) break } return source(context) } } /// The function signature for a completion source. Such a function /// may return its [result](#autocomplete.CompletionResult) /// synchronously or as a promise. Returning null indicates no /// completions are available. export type CompletionSource = (context: CompletionContext) => CompletionResult | null | Promise /// Interface for objects returned by completion sources. export interface CompletionResult { /// The start of the range that is being completed. from: number /// The end of the range that is being completed. Defaults to the /// main cursor position. to?: number /// The completions returned. These don't have to be compared with /// the input by the source—the autocompletion system will do its /// own matching (against the text between `from` and `to`) and /// sorting. options: readonly Completion[] /// When given, further typing or deletion that causes the part of /// the document between ([mapped](#state.ChangeDesc.mapPos)) `from` /// and `to` to match this regular expression or predicate function /// will not query the completion source again, but continue with /// this list of options. This can help a lot with responsiveness, /// since it allows the completion list to be updated synchronously. validFor?: RegExp | ((text: string, from: number, to: number, state: EditorState) => boolean) /// By default, the library filters and scores completions. Set /// `filter` to `false` to disable this, and cause your completions /// to all be included, in the order they were given. When there are /// other sources, unfiltered completions appear at the top of the /// list of completions. `validFor` must not be given when `filter` /// is `false`, because it only works when filtering. filter?: boolean /// When [`filter`](#autocomplete.CompletionResult.filter) is set to /// `false` or a completion has a /// [`displayLabel`](#autocomplete.Completion.displayLabel), this /// may be provided to compute the ranges on the label that match /// the input. Should return an array of numbers where each pair of /// adjacent numbers provide the start and end of a range. The /// second argument, the match found by the library, is only passed /// when `filter` isn't `false`. getMatch?: (completion: Completion, matched?: readonly number[]) => readonly number[] /// Synchronously update the completion result after typing or /// deletion. If given, this should not do any expensive work, since /// it will be called during editor state updates. The function /// should make sure (similar to /// [`validFor`](#autocomplete.CompletionResult.validFor)) that the /// completion still applies in the new state. update?: (current: CompletionResult, from: number, to: number, context: CompletionContext) => CompletionResult | null } export class Option { constructor(readonly completion: Completion, readonly source: CompletionSource, readonly match: readonly number[], public score: number) {} } export function cur(state: EditorState) { return state.selection.main.from } // Make sure the given regexp has a $ at its end and, if `start` is // true, a ^ at its start. export function ensureAnchor(expr: RegExp, start: boolean) { let {source} = expr let addStart = start && source[0] != "^", addEnd = source[source.length - 1] != "$" if (!addStart && !addEnd) return expr return new RegExp(`${addStart ? "^" : ""}(?:${source})${addEnd ? "$" : ""}`, expr.flags ?? (expr.ignoreCase ? "i" : "")) } /// This annotation is added to transactions that are produced by /// picking a completion. export const pickedCompletion = Annotation.define() /// Helper function that returns a transaction spec which inserts a /// completion's text in the main selection range, and any other /// selection range that has the same text in front of it. export function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec { let {main} = state.selection, fromOff = from - main.from, toOff = to - main.from return { ...state.changeByRange(range => { if (range != main && from != to && state.sliceDoc(range.from + fromOff, range.from + toOff) != state.sliceDoc(from, to)) return {range} return { changes: {from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: text}, range: EditorSelection.cursor(range.from + fromOff + text.length) } }), scrollIntoView: true, userEvent: "input.complete" } } const SourceCache = new WeakMap() export function asSource(source: CompletionSource | readonly (string | Completion)[]): CompletionSource { if (!Array.isArray(source)) return source as CompletionSource let known = SourceCache.get(source) if (!known) SourceCache.set(source, known = completeFromList(source)) return known } export const startCompletionEffect = StateEffect.define() export const closeCompletionEffect = StateEffect.define() autocomplete-6.11.1/src/config.ts000066400000000000000000000152601453106274700167220ustar00rootroot00000000000000import {Completion, CompletionSource} from "./completion" import {Info} from "./theme" import {Facet, combineConfig, EditorState} from "@codemirror/state" import {EditorView, Rect, Direction} from "@codemirror/view" export interface CompletionConfig { /// When enabled (defaults to true), autocompletion will start /// whenever the user types something that can be completed. activateOnTyping?: boolean /// By default, when completion opens, the first option is selected /// and can be confirmed with /// [`acceptCompletion`](#autocomplete.acceptCompletion). When this /// is set to false, the completion widget starts with no completion /// selected, and the user has to explicitly move to a completion /// before you can confirm one. selectOnOpen?: boolean /// Override the completion sources used. By default, they will be /// taken from the `"autocomplete"` [language /// data](#state.EditorState.languageDataAt) (which should hold /// [completion sources](#autocomplete.CompletionSource) or arrays /// of [completions](#autocomplete.Completion)). override?: readonly CompletionSource[] | null, /// Determines whether the completion tooltip is closed when the /// editor loses focus. Defaults to true. closeOnBlur?: boolean, /// The maximum number of options to render to the DOM. maxRenderedOptions?: number, /// Set this to false to disable the [default completion /// keymap](#autocomplete.completionKeymap). (This requires you to /// add bindings to control completion yourself. The bindings should /// probably have a higher precedence than other bindings for the /// same keys.) defaultKeymap?: boolean, /// By default, completions are shown below the cursor when there is /// space. Setting this to true will make the extension put the /// completions above the cursor when possible. aboveCursor?: boolean, /// When given, this may return an additional CSS class to add to /// the completion dialog element. tooltipClass?: (state: EditorState) => string, /// This can be used to add additional CSS classes to completion /// options. optionClass?: (completion: Completion) => string, /// By default, the library will render icons based on the /// completion's [type](#autocomplete.Completion.type) in front of /// each option. Set this to false to turn that off. icons?: boolean, /// This option can be used to inject additional content into /// options. The `render` function will be called for each visible /// completion, and should produce a DOM node to show. `position` /// determines where in the DOM the result appears, relative to /// other added widgets and the standard content. The default icons /// have position 20, the label position 50, and the detail position /// 80. addToOptions?: {render: (completion: Completion, state: EditorState, view: EditorView) => Node | null, position: number}[] /// By default, [info](#autocomplete.Completion.info) tooltips are /// placed to the side of the selected completion. This option can /// be used to override that. It will be given rectangles for the /// list of completions, the selected option, the info element, and /// the availble [tooltip /// space](#view.tooltips^config.tooltipSpace), and should return /// style and/or class strings for the info element. positionInfo?: (view: EditorView, list: Rect, option: Rect, info: Rect, space: Rect) => {style?: string, class?: string} /// The comparison function to use when sorting completions with the same /// match score. Defaults to using /// [`localeCompare`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare). compareCompletions?: (a: Completion, b: Completion) => number /// By default, commands relating to an open completion only take /// effect 75 milliseconds after the completion opened, so that key /// presses made before the user is aware of the tooltip don't go to /// the tooltip. This option can be used to configure that delay. interactionDelay?: number /// When there are multiple asynchronous completion sources, this /// controls how long the extension waits for a slow source before /// displaying results from faster sources. Defaults to 100 /// milliseconds. updateSyncTime?: number } export const completionConfig = Facet.define>({ combine(configs) { return combineConfig>(configs, { activateOnTyping: true, selectOnOpen: true, override: null, closeOnBlur: true, maxRenderedOptions: 100, defaultKeymap: true, tooltipClass: () => "", optionClass: () => "", aboveCursor: false, icons: true, addToOptions: [], positionInfo: defaultPositionInfo as any, compareCompletions: (a, b) => a.label.localeCompare(b.label), interactionDelay: 75, updateSyncTime: 100 }, { defaultKeymap: (a, b) => a && b, closeOnBlur: (a, b) => a && b, icons: (a, b) => a && b, tooltipClass: (a, b) => c => joinClass(a(c), b(c)), optionClass: (a, b) => c => joinClass(a(c), b(c)), addToOptions: (a, b) => a.concat(b) }) } }) function joinClass(a: string, b: string) { return a ? b ? a + " " + b : a : b } function defaultPositionInfo(view: EditorView, list: Rect, option: Rect, info: Rect, space: Rect, tooltip: HTMLElement) { let rtl = view.textDirection == Direction.RTL, left = rtl, narrow = false let side = "top", offset, maxWidth let spaceLeft = list.left - space.left, spaceRight = space.right - list.right let infoWidth = info.right - info.left, infoHeight = info.bottom - info.top if (left && spaceLeft < Math.min(infoWidth, spaceRight)) left = false else if (!left && spaceRight < Math.min(infoWidth, spaceLeft)) left = true if (infoWidth <= (left ? spaceLeft : spaceRight)) { offset = Math.max(space.top, Math.min(option.top, space.bottom - infoHeight)) - list.top maxWidth = Math.min(Info.Width, left ? spaceLeft : spaceRight) } else { narrow = true maxWidth = Math.min(Info.Width, (rtl ? list.right : space.right - list.left) - Info.Margin) let spaceBelow = space.bottom - list.bottom if (spaceBelow >= infoHeight || spaceBelow > list.top) { // Below the completion offset = option.bottom - list.top } else { // Above it side = "bottom" offset = list.bottom - option.top } } let scaleY = (list.bottom - list.top) / tooltip.offsetHeight let scaleX = (list.right - list.left) / tooltip.offsetWidth return { style: `${side}: ${offset / scaleY}px; max-width: ${maxWidth / scaleX}px`, class: "cm-completionInfo-" + (narrow ? (rtl ? "left-narrow" : "right-narrow") : left ? "left" : "right") } } autocomplete-6.11.1/src/filter.ts000066400000000000000000000143211453106274700167370ustar00rootroot00000000000000import {codePointAt, codePointSize, fromCodePoint} from "@codemirror/state" // Scores are counted from 0 (great match) down to negative numbers, // assigning specific penalty values for specific shortcomings. const enum Penalty { Gap = -1100, // Added for each gap in the match (not counted for by-word matches) NotStart = -700, // The match doesn't start at the start of the word CaseFold = -200, // At least one character needed to be case-folded to match ByWord = -100, // The match is by-word, meaning each char in the pattern matches the start of a word in the string NotFull = -100, // Used to push down matches that don't match the pattern fully relative to those that do } const enum Tp { NonWord, Upper, Lower } // A pattern matcher for fuzzy completion matching. Create an instance // once for a pattern, and then use that to match any number of // completions. export class FuzzyMatcher { chars: number[] = [] folded: number[] = [] astral: boolean // Buffers reused by calls to `match` to track matched character // positions. any: number[] = [] precise: number[] = [] byWord: number[] = [] score = 0 matched: readonly number[] = [] constructor(readonly pattern: string) { for (let p = 0; p < pattern.length;) { let char = codePointAt(pattern, p), size = codePointSize(char) this.chars.push(char) let part = pattern.slice(p, p + size), upper = part.toUpperCase() this.folded.push(codePointAt(upper == part ? part.toLowerCase() : upper, 0)) p += size } this.astral = pattern.length != this.chars.length } ret(score: number, matched: readonly number[]) { this.score = score this.matched = matched return true } // Matches a given word (completion) against the pattern (input). // Will return a boolean indicating whether there was a match and, // on success, set `this.score` to the score, `this.matched` to an // array of `from, to` pairs indicating the matched parts of `word`. // // The score is a number that is more negative the worse the match // is. See `Penalty` above. match(word: string): boolean { if (this.pattern.length == 0) return this.ret(Penalty.NotFull, []) if (word.length < this.pattern.length) return false let {chars, folded, any, precise, byWord} = this // For single-character queries, only match when they occur right // at the start if (chars.length == 1) { let first = codePointAt(word, 0), firstSize = codePointSize(first) let score = firstSize == word.length ? 0 : Penalty.NotFull if (first == chars[0]) {} else if (first == folded[0]) score += Penalty.CaseFold else return false return this.ret(score, [0, firstSize]) } let direct = word.indexOf(this.pattern) if (direct == 0) return this.ret(word.length == this.pattern.length ? 0 : Penalty.NotFull, [0, this.pattern.length]) let len = chars.length, anyTo = 0 if (direct < 0) { for (let i = 0, e = Math.min(word.length, 200); i < e && anyTo < len;) { let next = codePointAt(word, i) if (next == chars[anyTo] || next == folded[anyTo]) any[anyTo++] = i i += codePointSize(next) } // No match, exit immediately if (anyTo < len) return false } // This tracks the extent of the precise (non-folded, not // necessarily adjacent) match let preciseTo = 0 // Tracks whether there is a match that hits only characters that // appear to be starting words. `byWordFolded` is set to true when // a case folded character is encountered in such a match let byWordTo = 0, byWordFolded = false // If we've found a partial adjacent match, these track its state let adjacentTo = 0, adjacentStart = -1, adjacentEnd = -1 let hasLower = /[a-z]/.test(word), wordAdjacent = true // Go over the option's text, scanning for the various kinds of matches for (let i = 0, e = Math.min(word.length, 200), prevType = Tp.NonWord; i < e && byWordTo < len;) { let next = codePointAt(word, i) if (direct < 0) { if (preciseTo < len && next == chars[preciseTo]) precise[preciseTo++] = i if (adjacentTo < len) { if (next == chars[adjacentTo] || next == folded[adjacentTo]) { if (adjacentTo == 0) adjacentStart = i adjacentEnd = i + 1 adjacentTo++ } else { adjacentTo = 0 } } } let ch, type = next < 0xff ? (next >= 48 && next <= 57 || next >= 97 && next <= 122 ? Tp.Lower : next >= 65 && next <= 90 ? Tp.Upper : Tp.NonWord) : ((ch = fromCodePoint(next)) != ch.toLowerCase() ? Tp.Upper : ch != ch.toUpperCase() ? Tp.Lower : Tp.NonWord) if (!i || type == Tp.Upper && hasLower || prevType == Tp.NonWord && type != Tp.NonWord) { if (chars[byWordTo] == next || (folded[byWordTo] == next && (byWordFolded = true))) byWord[byWordTo++] = i else if (byWord.length) wordAdjacent = false } prevType = type i += codePointSize(next) } if (byWordTo == len && byWord[0] == 0 && wordAdjacent) return this.result(Penalty.ByWord + (byWordFolded ? Penalty.CaseFold : 0), byWord, word) if (adjacentTo == len && adjacentStart == 0) return this.ret(Penalty.CaseFold - word.length + (adjacentEnd == word.length ? 0 : Penalty.NotFull), [0, adjacentEnd]) if (direct > -1) return this.ret(Penalty.NotStart - word.length, [direct, direct + this.pattern.length]) if (adjacentTo == len) return this.ret(Penalty.CaseFold + Penalty.NotStart - word.length, [adjacentStart, adjacentEnd]) if (byWordTo == len) return this.result(Penalty.ByWord + (byWordFolded ? Penalty.CaseFold : 0) + Penalty.NotStart + (wordAdjacent ? 0 : Penalty.Gap), byWord, word) return chars.length == 2 ? false : this.result((any[0] ? Penalty.NotStart : 0) + Penalty.CaseFold + Penalty.Gap, any, word) } result(score: number, positions: number[], word: string) { let result: number[] = [], i = 0 for (let pos of positions) { let to = pos + (this.astral ? codePointSize(codePointAt(word, pos)) : 1) if (i && result[i - 1] == pos) result[i - 1] = to else { result[i++] = pos; result[i++] = to } } return this.ret(score - word.length, result) } } autocomplete-6.11.1/src/index.ts000066400000000000000000000105741453106274700165670ustar00rootroot00000000000000import {Prec, Extension, EditorState, StateEffect} from "@codemirror/state" import {keymap, KeyBinding} from "@codemirror/view" import {Completion, Option} from "./completion" import {completionState, State, setSelectedEffect} from "./state" import {CompletionConfig, completionConfig} from "./config" import {completionPlugin, moveCompletionSelection, acceptCompletion, startCompletion, closeCompletion} from "./view" import {baseTheme} from "./theme" export {snippet, snippetCompletion, nextSnippetField, prevSnippetField, hasNextSnippetField, hasPrevSnippetField, clearSnippet, snippetKeymap} from "./snippet" export {Completion, CompletionInfo, CompletionSection, CompletionContext, CompletionSource, CompletionResult, pickedCompletion, completeFromList, ifIn, ifNotIn, insertCompletionText} from "./completion" export {startCompletion, closeCompletion, acceptCompletion, moveCompletionSelection} from "./view" export {completeAnyWord} from "./word" export {CloseBracketConfig, closeBrackets, closeBracketsKeymap, deleteBracketPair, insertBracket} from "./closebrackets" /// Returns an extension that enables autocompletion. export function autocompletion(config: CompletionConfig = {}): Extension { return [ completionState, completionConfig.of(config), completionPlugin, completionKeymapExt, baseTheme ] } /// Basic keybindings for autocompletion. /// /// - Ctrl-Space: [`startCompletion`](#autocomplete.startCompletion) /// - Escape: [`closeCompletion`](#autocomplete.closeCompletion) /// - ArrowDown: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(true)` /// - ArrowUp: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(false)` /// - PageDown: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(true, "page")` /// - PageDown: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(true, "page")` /// - Enter: [`acceptCompletion`](#autocomplete.acceptCompletion) export const completionKeymap: readonly KeyBinding[] = [ {key: "Ctrl-Space", run: startCompletion}, {key: "Escape", run: closeCompletion}, {key: "ArrowDown", run: moveCompletionSelection(true)}, {key: "ArrowUp", run: moveCompletionSelection(false)}, {key: "PageDown", run: moveCompletionSelection(true, "page")}, {key: "PageUp", run: moveCompletionSelection(false, "page")}, {key: "Enter", run: acceptCompletion} ] const completionKeymapExt = Prec.highest(keymap.computeN([completionConfig], state => state.facet(completionConfig).defaultKeymap ? [completionKeymap] : [])) /// Get the current completion status. When completions are available, /// this will return `"active"`. When completions are pending (in the /// process of being queried), this returns `"pending"`. Otherwise, it /// returns `null`. export function completionStatus(state: EditorState): null | "active" | "pending" { let cState = state.field(completionState, false) return cState && cState.active.some(a => a.state == State.Pending) ? "pending" : cState && cState.active.some(a => a.state != State.Inactive) ? "active" : null } const completionArrayCache: WeakMap = new WeakMap /// Returns the available completions as an array. export function currentCompletions(state: EditorState): readonly Completion[] { let open = state.field(completionState, false)?.open if (!open || open.disabled) return [] let completions = completionArrayCache.get(open.options) if (!completions) completionArrayCache.set(open.options, completions = open.options.map(o => o.completion)) return completions } /// Return the currently selected completion, if any. export function selectedCompletion(state: EditorState): Completion | null { let open = state.field(completionState, false)?.open return open && !open.disabled && open.selected >= 0 ? open.options[open.selected].completion : null } /// Returns the currently selected position in the active completion /// list, or null if no completions are active. export function selectedCompletionIndex(state: EditorState): number | null { let open = state.field(completionState, false)?.open return open && !open.disabled && open.selected >= 0 ? open.selected : null } /// Create an effect that can be attached to a transaction to change /// the currently selected completion. export function setSelectedCompletion(index: number): StateEffect { return setSelectedEffect.of(index) } autocomplete-6.11.1/src/snippet.ts000066400000000000000000000243761453106274700171470ustar00rootroot00000000000000import {Decoration, DecorationSet, WidgetType, EditorView, keymap, KeyBinding} from "@codemirror/view" import {StateField, StateEffect, ChangeDesc, EditorState, EditorSelection, Transaction, TransactionSpec, Text, StateCommand, Prec, Facet, MapMode} from "@codemirror/state" import {indentUnit} from "@codemirror/language" import {baseTheme} from "./theme" import {Completion, pickedCompletion} from "./completion" class FieldPos { constructor(public field: number, readonly line: number, public from: number, public to: number) {} } class FieldRange { constructor(readonly field: number, readonly from: number, readonly to: number) {} map(changes: ChangeDesc) { let from = changes.mapPos(this.from, -1, MapMode.TrackDel) let to = changes.mapPos(this.to, 1, MapMode.TrackDel) return from == null || to == null ? null : new FieldRange(this.field, from, to) } } class Snippet { constructor(readonly lines: readonly string[], readonly fieldPositions: readonly FieldPos[]) {} instantiate(state: EditorState, pos: number) { let text = [], lineStart = [pos] let lineObj = state.doc.lineAt(pos), baseIndent = /^\s*/.exec(lineObj.text)![0] for (let line of this.lines) { if (text.length) { let indent = baseIndent, tabs = /^\t*/.exec(line)![0].length for (let i = 0; i < tabs; i++) indent += state.facet(indentUnit) lineStart.push(pos + indent.length - tabs) line = indent + line.slice(tabs) } text.push(line) pos += line.length + 1 } let ranges = this.fieldPositions.map( pos => new FieldRange(pos.field, lineStart[pos.line] + pos.from, lineStart[pos.line] + pos.to)) return {text, ranges} } static parse(template: string) { let fields: {seq: number | null, name: string}[] = [] let lines = [], positions = [], m for (let line of template.split(/\r\n?|\n/)) { while (m = /[#$]\{(?:(\d+)(?::([^}]*))?|([^}]*))\}/.exec(line)) { let seq = m[1] ? +m[1] : null, name = m[2] || m[3] || "", found = -1 for (let i = 0; i < fields.length; i++) { if (seq != null ? fields[i].seq == seq : name ? fields[i].name == name : false) found = i } if (found < 0) { let i = 0 while (i < fields.length && (seq == null || (fields[i].seq != null && fields[i].seq! < seq))) i++ fields.splice(i, 0, {seq, name}) found = i for (let pos of positions) if (pos.field >= found) pos.field++ } positions.push(new FieldPos(found, lines.length, m.index, m.index + name.length)) line = line.slice(0, m.index) + name + line.slice(m.index + m[0].length) } for (let esc; esc = /\\([{}])/.exec(line);) { line = line.slice(0, esc.index) + esc[1] + line.slice(esc.index + esc[0].length) for (let pos of positions) if (pos.line == lines.length && pos.from > esc.index) { pos.from-- pos.to-- } } lines.push(line) } return new Snippet(lines, positions) } } let fieldMarker = Decoration.widget({widget: new class extends WidgetType { toDOM() { let span = document.createElement("span") span.className = "cm-snippetFieldPosition" return span } ignoreEvent() { return false } }}) let fieldRange = Decoration.mark({class: "cm-snippetField"}) class ActiveSnippet { deco: DecorationSet constructor(readonly ranges: readonly FieldRange[], readonly active: number) { this.deco = Decoration.set(ranges.map(r => (r.from == r.to ? fieldMarker : fieldRange).range(r.from, r.to))) } map(changes: ChangeDesc) { let ranges = [] for (let r of this.ranges) { let mapped = r.map(changes) if (!mapped) return null ranges.push(mapped) } return new ActiveSnippet(ranges, this.active) } selectionInsideField(sel: EditorSelection) { return sel.ranges.every( range => this.ranges.some(r => r.field == this.active && r.from <= range.from && r.to >= range.to)) } } const setActive = StateEffect.define({ map(value, changes) { return value && value.map(changes) } }) const moveToField = StateEffect.define() const snippetState = StateField.define({ create() { return null }, update(value, tr) { for (let effect of tr.effects) { if (effect.is(setActive)) return effect.value if (effect.is(moveToField) && value) return new ActiveSnippet(value.ranges, effect.value) } if (value && tr.docChanged) value = value.map(tr.changes) if (value && tr.selection && !value.selectionInsideField(tr.selection)) value = null return value }, provide: f => EditorView.decorations.from(f, val => val ? val.deco : Decoration.none) }) function fieldSelection(ranges: readonly FieldRange[], field: number) { return EditorSelection.create(ranges.filter(r => r.field == field).map(r => EditorSelection.range(r.from, r.to))) } /// Convert a snippet template to a function that can /// [apply](#autocomplete.Completion.apply) it. Snippets are written /// using syntax like this: /// /// "for (let ${index} = 0; ${index} < ${end}; ${index}++) {\n\t${}\n}" /// /// Each `${}` placeholder (you may also use `#{}`) indicates a field /// that the user can fill in. Its name, if any, will be the default /// content for the field. /// /// When the snippet is activated by calling the returned function, /// the code is inserted at the given position. Newlines in the /// template are indented by the indentation of the start line, plus /// one [indent unit](#language.indentUnit) per tab character after /// the newline. /// /// On activation, (all instances of) the first field are selected. /// The user can move between fields with Tab and Shift-Tab as long as /// the fields are active. Moving to the last field or moving the /// cursor out of the current field deactivates the fields. /// /// The order of fields defaults to textual order, but you can add /// numbers to placeholders (`${1}` or `${1:defaultText}`) to provide /// a custom order. /// /// To include a literal `{` or `}` in your template, put a backslash /// in front of it. This will be removed and the brace will not be /// interpreted as indicating a placeholder. export function snippet(template: string) { let snippet = Snippet.parse(template) return (editor: {state: EditorState, dispatch: (tr: Transaction) => void}, completion: Completion | null, from: number, to: number) => { let {text, ranges} = snippet.instantiate(editor.state, from) let spec: TransactionSpec = { changes: {from, to, insert: Text.of(text)}, scrollIntoView: true, annotations: completion ? pickedCompletion.of(completion) : undefined } if (ranges.length) spec.selection = fieldSelection(ranges, 0) if (ranges.length > 1) { let active = new ActiveSnippet(ranges, 0) let effects: StateEffect[] = spec.effects = [setActive.of(active)] if (editor.state.field(snippetState, false) === undefined) effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme])) } editor.dispatch(editor.state.update(spec)) } } function moveField(dir: 1 | -1): StateCommand { return ({state, dispatch}) => { let active = state.field(snippetState, false) if (!active || dir < 0 && active.active == 0) return false let next = active.active + dir, last = dir > 0 && !active.ranges.some(r => r.field == next + dir) dispatch(state.update({ selection: fieldSelection(active.ranges, next), effects: setActive.of(last ? null : new ActiveSnippet(active.ranges, next)), scrollIntoView: true })) return true } } /// A command that clears the active snippet, if any. export const clearSnippet: StateCommand = ({state, dispatch}) => { let active = state.field(snippetState, false) if (!active) return false dispatch(state.update({effects: setActive.of(null)})) return true } /// Move to the next snippet field, if available. export const nextSnippetField = moveField(1) /// Move to the previous snippet field, if available. export const prevSnippetField = moveField(-1) /// Check if there is an active snippet with a next field for /// `nextSnippetField` to move to. export function hasNextSnippetField(state: EditorState) { let active = state.field(snippetState, false) return !!(active && active.ranges.some(r => r.field == active!.active + 1)) } /// Returns true if there is an active snippet and a previous field /// for `prevSnippetField` to move to. export function hasPrevSnippetField(state: EditorState) { let active = state.field(snippetState, false) return !!(active && active.active > 0) } const defaultSnippetKeymap = [ {key: "Tab", run: nextSnippetField, shift: prevSnippetField}, {key: "Escape", run: clearSnippet} ] /// A facet that can be used to configure the key bindings used by /// snippets. The default binds Tab to /// [`nextSnippetField`](#autocomplete.nextSnippetField), Shift-Tab to /// [`prevSnippetField`](#autocomplete.prevSnippetField), and Escape /// to [`clearSnippet`](#autocomplete.clearSnippet). export const snippetKeymap = Facet.define({ combine(maps) { return maps.length ? maps[0] : defaultSnippetKeymap } }) const addSnippetKeymap = Prec.highest(keymap.compute([snippetKeymap], state => state.facet(snippetKeymap))) /// Create a completion from a snippet. Returns an object with the /// properties from `completion`, plus an `apply` function that /// applies the snippet. export function snippetCompletion(template: string, completion: Completion): Completion { return {...completion, apply: snippet(template)} } const snippetPointerHandler = EditorView.domEventHandlers({ mousedown(event, view) { let active = view.state.field(snippetState, false), pos: number | null if (!active || (pos = view.posAtCoords({x: event.clientX, y: event.clientY})) == null) return false let match = active.ranges.find(r => r.from <= pos! && r.to >= pos!) if (!match || match.field == active.active) return false view.dispatch({ selection: fieldSelection(active.ranges, match.field), effects: setActive.of(active.ranges.some(r => r.field > match!.field) ? new ActiveSnippet(active.ranges, match.field) : null), scrollIntoView: true }) return true } }) autocomplete-6.11.1/src/state.ts000066400000000000000000000312111453106274700165670ustar00rootroot00000000000000import {EditorView, Tooltip, showTooltip} from "@codemirror/view" import {Transaction, StateField, StateEffect, EditorState, ChangeDesc} from "@codemirror/state" import {Option, CompletionSource, CompletionResult, cur, asSource, Completion, ensureAnchor, CompletionContext, CompletionSection, startCompletionEffect, closeCompletionEffect, insertCompletionText, pickedCompletion} from "./completion" import {FuzzyMatcher} from "./filter" import {completionTooltip} from "./tooltip" import {CompletionConfig, completionConfig} from "./config" // Used to pick a preferred option when two options with the same // label occur in the result. function score(option: Completion) { return (option.boost || 0) * 100 + (option.apply ? 10 : 0) + (option.info ? 5 : 0) + (option.type ? 1 : 0) } function sortOptions(active: readonly ActiveSource[], state: EditorState) { let options: Option[] = [] let sections: null | CompletionSection[] = null let addOption = (option: Option) => { options.push(option) let {section} = option.completion if (section) { if (!sections) sections = [] let name = typeof section == "string" ? section : section.name if (!sections.some(s => s.name == name)) sections.push(typeof section == "string" ? {name} : section) } } for (let a of active) if (a.hasResult()) { let getMatch = a.result.getMatch if (a.result.filter === false) { for (let option of a.result.options) { addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length)) } } else { let matcher = new FuzzyMatcher(state.sliceDoc(a.from, a.to)) for (let option of a.result.options) if (matcher.match(option.label)) { let matched = !option.displayLabel ? matcher.matched : getMatch ? getMatch(option, matcher.matched) : [] addOption(new Option(option, a.source, matched, matcher.score + (option.boost || 0))) } } } if (sections) { let sectionOrder: {[name: string]: number} = Object.create(null), pos = 0 let cmp = (a: CompletionSection, b: CompletionSection) => (a.rank ?? 1e9) - (b.rank ?? 1e9) || (a.name < b.name ? -1 : 1) for (let s of (sections as CompletionSection[]).sort(cmp)) { pos -= 1e5 sectionOrder[s.name] = pos } for (let option of options) { let {section} = option.completion if (section) option.score += sectionOrder[typeof section == "string" ? section : section.name] } } let result = [], prev = null let compare = state.facet(completionConfig).compareCompletions for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) { let cur = opt.completion if (!prev || prev.label != cur.label || prev.detail != cur.detail || (prev.type != null && cur.type != null && prev.type != cur.type) || prev.apply != cur.apply || prev.boost != cur.boost) result.push(opt) else if (score(opt.completion) > score(prev)) result[result.length - 1] = opt prev = opt.completion } return result } class CompletionDialog { constructor(readonly options: readonly Option[], readonly attrs: {[name: string]: string}, readonly tooltip: Tooltip, readonly timestamp: number, readonly selected: number, readonly disabled: boolean) {} setSelected(selected: number, id: string) { return selected == this.selected || selected >= this.options.length ? this : new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled) } static build( active: readonly ActiveSource[], state: EditorState, id: string, prev: CompletionDialog | null, conf: Required ): CompletionDialog | null { let options = sortOptions(active, state) if (!options.length) { return prev && active.some(a => a.state == State.Pending) ? new CompletionDialog(prev.options, prev.attrs, prev.tooltip, prev.timestamp, prev.selected, true) : null } let selected = state.facet(completionConfig).selectOnOpen ? 0 : -1 if (prev && prev.selected != selected && prev.selected != -1) { let selectedValue = prev.options[prev.selected].completion for (let i = 0; i < options.length; i++) if (options[i].completion == selectedValue) { selected = i break } } return new CompletionDialog(options, makeAttrs(id, selected), { pos: active.reduce((a, b) => b.hasResult() ? Math.min(a, b.from) : a, 1e8), create: createTooltip, above: conf.aboveCursor, }, prev ? prev.timestamp : Date.now(), selected, false) } map(changes: ChangeDesc) { return new CompletionDialog(this.options, this.attrs, {...this.tooltip, pos: changes.mapPos(this.tooltip.pos)}, this.timestamp, this.selected, this.disabled) } } export class CompletionState { constructor(readonly active: readonly ActiveSource[], readonly id: string, readonly open: CompletionDialog | null) {} static start() { return new CompletionState(none, "cm-ac-" + Math.floor(Math.random() * 2e6).toString(36), null) } update(tr: Transaction) { let {state} = tr, conf = state.facet(completionConfig) let sources = conf.override || state.languageDataAt("autocomplete", cur(state)).map(asSource) let active: readonly ActiveSource[] = sources.map(source => { let value = this.active.find(s => s.source == source) || new ActiveSource(source, this.active.some(a => a.state != State.Inactive) ? State.Pending : State.Inactive) return value.update(tr, conf) }) if (active.length == this.active.length && active.every((a, i) => a == this.active[i])) active = this.active let open = this.open if (open && tr.docChanged) open = open.map(tr.changes) if (tr.selection || active.some(a => a.hasResult() && tr.changes.touchesRange(a.from, a.to)) || !sameResults(active, this.active)) open = CompletionDialog.build(active, state, this.id, open, conf) else if (open && open.disabled && !active.some(a => a.state == State.Pending)) open = null if (!open && active.every(a => a.state != State.Pending) && active.some(a => a.hasResult())) active = active.map(a => a.hasResult() ? new ActiveSource(a.source, State.Inactive) : a) for (let effect of tr.effects) if (effect.is(setSelectedEffect)) open = open && open.setSelected(effect.value, this.id) return active == this.active && open == this.open ? this : new CompletionState(active, this.id, open) } get tooltip(): Tooltip | null { return this.open ? this.open.tooltip : null } get attrs() { return this.open ? this.open.attrs : baseAttrs } } function sameResults(a: readonly ActiveSource[], b: readonly ActiveSource[]) { if (a == b) return true for (let iA = 0, iB = 0;;) { while (iA < a.length && !a[iA].hasResult) iA++ while (iB < b.length && !b[iB].hasResult) iB++ let endA = iA == a.length, endB = iB == b.length if (endA || endB) return endA == endB if ((a[iA++] as ActiveResult).result != (b[iB++] as ActiveResult).result) return false } } const baseAttrs = { "aria-autocomplete": "list" } function makeAttrs(id: string, selected: number) { let result: {[name: string]: string} = { "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-controls": id } if (selected > -1) result["aria-activedescendant"] = id + "-" + selected return result } const none: readonly any[] = [] export const enum State { Inactive = 0, Pending = 1, Result = 2 } export function getUserEvent(tr: Transaction): "input" | "delete" | null { return tr.isUserEvent("input.type") ? "input" : tr.isUserEvent("delete.backward") ? "delete" : null } export class ActiveSource { constructor(readonly source: CompletionSource, readonly state: State, readonly explicitPos: number = -1) {} hasResult(): this is ActiveResult { return false } update(tr: Transaction, conf: Required): ActiveSource { let event = getUserEvent(tr), value: ActiveSource = this if (event) value = value.handleUserEvent(tr, event, conf) else if (tr.docChanged) value = value.handleChange(tr) else if (tr.selection && value.state != State.Inactive) value = new ActiveSource(value.source, State.Inactive) for (let effect of tr.effects) { if (effect.is(startCompletionEffect)) value = new ActiveSource(value.source, State.Pending, effect.value ? cur(tr.state) : -1) else if (effect.is(closeCompletionEffect)) value = new ActiveSource(value.source, State.Inactive) else if (effect.is(setActiveEffect)) for (let active of effect.value) if (active.source == value.source) value = active } return value } handleUserEvent(tr: Transaction, type: "input" | "delete", conf: Required): ActiveSource { return type == "delete" || !conf.activateOnTyping ? this.map(tr.changes) : new ActiveSource(this.source, State.Pending) } handleChange(tr: Transaction): ActiveSource { return tr.changes.touchesRange(cur(tr.startState)) ? new ActiveSource(this.source, State.Inactive) : this.map(tr.changes) } map(changes: ChangeDesc) { return changes.empty || this.explicitPos < 0 ? this : new ActiveSource(this.source, this.state, changes.mapPos(this.explicitPos)) } } export class ActiveResult extends ActiveSource { constructor(source: CompletionSource, explicitPos: number, readonly result: CompletionResult, readonly from: number, readonly to: number) { super(source, State.Result, explicitPos) } hasResult(): this is ActiveResult { return true } handleUserEvent(tr: Transaction, type: "input" | "delete", conf: Required): ActiveSource { let from = tr.changes.mapPos(this.from), to = tr.changes.mapPos(this.to, 1) let pos = cur(tr.state) if ((this.explicitPos < 0 ? pos <= from : pos < this.from) || pos > to || type == "delete" && cur(tr.startState) == this.from) return new ActiveSource(this.source, type == "input" && conf.activateOnTyping ? State.Pending : State.Inactive) let explicitPos = this.explicitPos < 0 ? -1 : tr.changes.mapPos(this.explicitPos), updated if (checkValid(this.result.validFor, tr.state, from, to)) return new ActiveResult(this.source, explicitPos, this.result, from, to) if (this.result.update && (updated = this.result.update(this.result, from, to, new CompletionContext(tr.state, pos, explicitPos >= 0)))) return new ActiveResult(this.source, explicitPos, updated, updated.from, updated.to ?? cur(tr.state)) return new ActiveSource(this.source, State.Pending, explicitPos) } handleChange(tr: Transaction): ActiveSource { return tr.changes.touchesRange(this.from, this.to) ? new ActiveSource(this.source, State.Inactive) : this.map(tr.changes) } map(mapping: ChangeDesc) { return mapping.empty ? this : new ActiveResult(this.source, this.explicitPos < 0 ? -1 : mapping.mapPos(this.explicitPos), this.result, mapping.mapPos(this.from), mapping.mapPos(this.to, 1)) } } function checkValid(validFor: undefined | RegExp | ((text: string, from: number, to: number, state: EditorState) => boolean), state: EditorState, from: number, to: number) { if (!validFor) return false let text = state.sliceDoc(from, to) return typeof validFor == "function" ? validFor(text, from, to, state) : ensureAnchor(validFor, true).test(text) } export const setActiveEffect = StateEffect.define({ map(sources, mapping) { return sources.map(s => s.map(mapping)) } }) export const setSelectedEffect = StateEffect.define() export const completionState = StateField.define({ create() { return CompletionState.start() }, update(value, tr) { return value.update(tr) }, provide: f => [ showTooltip.from(f, val => val.tooltip), EditorView.contentAttributes.from(f, state => state.attrs) ] }) export function applyCompletion(view: EditorView, option: Option) { const apply = option.completion.apply || option.completion.label let result = view.state.field(completionState).active.find(a => a.source == option.source) if (!(result instanceof ActiveResult)) return false if (typeof apply == "string") view.dispatch({ ...insertCompletionText(view.state, apply, result.from, result.to), annotations: pickedCompletion.of(option.completion) }) else apply(view, option.completion, result.from, result.to) return true } const createTooltip = completionTooltip(completionState, applyCompletion) autocomplete-6.11.1/src/theme.ts000066400000000000000000000067751453106274700165720ustar00rootroot00000000000000import {EditorView} from "@codemirror/view" export const enum Info { Margin = 30, Width = 400 } export const baseTheme = EditorView.baseTheme({ ".cm-tooltip.cm-tooltip-autocomplete": { "& > ul": { fontFamily: "monospace", whiteSpace: "nowrap", overflow: "hidden auto", maxWidth_fallback: "700px", maxWidth: "min(700px, 95vw)", minWidth: "250px", maxHeight: "10em", height: "100%", listStyle: "none", margin: 0, padding: 0, "& > li, & > completion-section": { padding: "1px 3px", lineHeight: 1.2 }, "& > li": { overflowX: "hidden", textOverflow: "ellipsis", cursor: "pointer" }, "& > completion-section": { display: "list-item", borderBottom: "1px solid silver", paddingLeft: "0.5em", opacity: 0.7 } } }, "&light .cm-tooltip-autocomplete ul li[aria-selected]": { background: "#17c", color: "white", }, "&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]": { background: "#777", }, "&dark .cm-tooltip-autocomplete ul li[aria-selected]": { background: "#347", color: "white", }, "&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]": { background: "#444", }, ".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after": { content: '"···"', opacity: 0.5, display: "block", textAlign: "center" }, ".cm-tooltip.cm-completionInfo": { position: "absolute", padding: "3px 9px", width: "max-content", maxWidth: `${Info.Width}px`, boxSizing: "border-box" }, ".cm-completionInfo.cm-completionInfo-left": { right: "100%" }, ".cm-completionInfo.cm-completionInfo-right": { left: "100%" }, ".cm-completionInfo.cm-completionInfo-left-narrow": { right: `${Info.Margin}px` }, ".cm-completionInfo.cm-completionInfo-right-narrow": { left: `${Info.Margin}px` }, "&light .cm-snippetField": {backgroundColor: "#00000022"}, "&dark .cm-snippetField": {backgroundColor: "#ffffff22"}, ".cm-snippetFieldPosition": { verticalAlign: "text-top", width: 0, height: "1.15em", display: "inline-block", margin: "0 -0.7px -.7em", borderLeft: "1.4px dotted #888" }, ".cm-completionMatchedText": { textDecoration: "underline" }, ".cm-completionDetail": { marginLeft: "0.5em", fontStyle: "italic" }, ".cm-completionIcon": { fontSize: "90%", width: ".8em", display: "inline-block", textAlign: "center", paddingRight: ".6em", opacity: "0.6", boxSizing: "content-box" }, ".cm-completionIcon-function, .cm-completionIcon-method": { "&:after": { content: "'ƒ'" } }, ".cm-completionIcon-class": { "&:after": { content: "'○'" } }, ".cm-completionIcon-interface": { "&:after": { content: "'◌'" } }, ".cm-completionIcon-variable": { "&:after": { content: "'𝑥'" } }, ".cm-completionIcon-constant": { "&:after": { content: "'𝐶'" } }, ".cm-completionIcon-type": { "&:after": { content: "'𝑡'" } }, ".cm-completionIcon-enum": { "&:after": { content: "'∪'" } }, ".cm-completionIcon-property": { "&:after": { content: "'□'" } }, ".cm-completionIcon-keyword": { "&:after": { content: "'🔑\uFE0E'" } // Disable emoji rendering }, ".cm-completionIcon-namespace": { "&:after": { content: "'▢'" } }, ".cm-completionIcon-text": { "&:after": { content: "'abc'", fontSize: "50%", verticalAlign: "middle" } } }) autocomplete-6.11.1/src/tooltip.ts000066400000000000000000000272171453106274700171540ustar00rootroot00000000000000import {EditorView, ViewUpdate, logException, TooltipView, Rect} from "@codemirror/view" import {StateField, EditorState} from "@codemirror/state" import {CompletionState} from "./state" import {completionConfig, CompletionConfig} from "./config" import {Option, Completion, CompletionInfo, closeCompletionEffect} from "./completion" type OptionContentSource = (completion: Completion, state: EditorState, view: EditorView, match: readonly number[]) => Node | null function optionContent(config: Required): OptionContentSource[] { let content = config.addToOptions.slice() as {render: OptionContentSource, position: number}[] if (config.icons) content.push({ render(completion: Completion) { let icon = document.createElement("div") icon.classList.add("cm-completionIcon") if (completion.type) icon.classList.add(...completion.type.split(/\s+/g).map(cls => "cm-completionIcon-" + cls)) icon.setAttribute("aria-hidden", "true") return icon }, position: 20 }) content.push({ render(completion: Completion, _s: EditorState, _v: EditorView, match: readonly number[]) { let labelElt = document.createElement("span") labelElt.className = "cm-completionLabel" let label = completion.displayLabel || completion.label, off = 0 for (let j = 0; j < match.length;) { let from = match[j++], to = match[j++] if (from > off) labelElt.appendChild(document.createTextNode(label.slice(off, from))) let span = labelElt.appendChild(document.createElement("span")) span.appendChild(document.createTextNode(label.slice(from, to))) span.className = "cm-completionMatchedText" off = to } if (off < label.length) labelElt.appendChild(document.createTextNode(label.slice(off))) return labelElt }, position: 50 }, { render(completion: Completion) { if (!completion.detail) return null let detailElt = document.createElement("span") detailElt.className = "cm-completionDetail" detailElt.textContent = completion.detail return detailElt }, position: 80 }) return content.sort((a, b) => a.position - b.position).map(a => a.render) } function rangeAroundSelected(total: number, selected: number, max: number) { if (total <= max) return {from: 0, to: total} if (selected < 0) selected = 0 if (selected <= (total >> 1)) { let off = Math.floor(selected / max) return {from: off * max, to: (off + 1) * max} } let off = Math.floor((total - selected) / max) return {from: total - (off + 1) * max, to: total - off * max} } class CompletionTooltip { dom: HTMLElement info: HTMLElement | null = null infoDestroy: (() => void) | null = null list!: HTMLElement placeInfoReq = { read: () => this.measureInfo(), write: (pos: {style?: string, class?: string} | null) => this.placeInfo(pos), key: this } range: {from: number, to: number} space: Rect | null = null optionContent: OptionContentSource[] tooltipClass: (state: EditorState) => string currentClass = "" optionClass: (option: Completion) => string constructor(readonly view: EditorView, readonly stateField: StateField, readonly applyCompletion: (view: EditorView, option: Option) => void) { let cState = view.state.field(stateField) let {options, selected} = cState.open! let config = view.state.facet(completionConfig) this.optionContent = optionContent(config) this.optionClass = config.optionClass this.tooltipClass = config.tooltipClass this.range = rangeAroundSelected(options.length, selected, config.maxRenderedOptions) this.dom = document.createElement("div") this.dom.className = "cm-tooltip-autocomplete" this.updateTooltipClass(view.state) this.dom.addEventListener("mousedown", (e: MouseEvent) => { let {options} = view.state.field(stateField).open! for (let dom = e.target as HTMLElement | null, match; dom && dom != this.dom; dom = dom.parentNode as HTMLElement) { if (dom.nodeName == "LI" && (match = /-(\d+)$/.exec(dom.id)) && +match[1] < options.length) { this.applyCompletion(view, options[+match[1]]) e.preventDefault() return } } }) this.dom.addEventListener("focusout", (e: FocusEvent) => { let state = view.state.field(this.stateField, false) if (state && state.tooltip && view.state.facet(completionConfig).closeOnBlur && e.relatedTarget != view.contentDOM) view.dispatch({effects: closeCompletionEffect.of(null)}) }) this.showOptions(options, cState.id) } mount() { this.updateSel() } showOptions(options: readonly Option[], id: string) { if (this.list) this.list.remove() this.list = this.dom.appendChild(this.createListBox(options, id, this.range)) this.list.addEventListener("scroll", () => { if (this.info) this.view.requestMeasure(this.placeInfoReq) }) } update(update: ViewUpdate) { let cState = update.state.field(this.stateField) let prevState = update.startState.field(this.stateField) this.updateTooltipClass(update.state) if (cState != prevState) { let {options, selected, disabled} = cState.open! if (!prevState.open || prevState.open.options != options) { this.range = rangeAroundSelected(options.length, selected, update.state.facet(completionConfig).maxRenderedOptions) this.showOptions(options, cState.id) } this.updateSel() if (disabled != prevState.open?.disabled) this.dom.classList.toggle("cm-tooltip-autocomplete-disabled", !!disabled) } } updateTooltipClass(state: EditorState) { let cls = this.tooltipClass(state) if (cls != this.currentClass) { for (let c of this.currentClass.split(" ")) if (c) this.dom.classList.remove(c) for (let c of cls.split(" ")) if (c) this.dom.classList.add(c) this.currentClass = cls } } positioned(space: Rect) { this.space = space if (this.info) this.view.requestMeasure(this.placeInfoReq) } updateSel() { let cState = this.view.state.field(this.stateField), open = cState.open! if (open.selected > -1 && open.selected < this.range.from || open.selected >= this.range.to) { this.range = rangeAroundSelected(open.options.length, open.selected, this.view.state.facet(completionConfig).maxRenderedOptions) this.showOptions(open.options, cState.id) } if (this.updateSelectedOption(open.selected)) { this.destroyInfo() let {completion} = open.options[open.selected] let {info} = completion if (!info) return let infoResult = typeof info === "string" ? document.createTextNode(info) : info(completion) if (!infoResult) return if ("then" in infoResult) { infoResult.then(obj => { if (obj && this.view.state.field(this.stateField, false) == cState) this.addInfoPane(obj, completion) }).catch(e => logException(this.view.state, e, "completion info")) } else { this.addInfoPane(infoResult, completion) } } } addInfoPane(content: NonNullable, completion: Completion) { this.destroyInfo() let wrap = this.info = document.createElement("div") wrap.className = "cm-tooltip cm-completionInfo" if ((content as Node).nodeType != null) { wrap.appendChild(content as Node) this.infoDestroy = null } else { let {dom, destroy} = content as {dom: Node, destroy?(): void} wrap.appendChild(dom) this.infoDestroy = destroy || null } this.dom.appendChild(wrap) this.view.requestMeasure(this.placeInfoReq) } updateSelectedOption(selected: number) { let set: null | HTMLElement = null for (let opt = this.list.firstChild as (HTMLElement | null), i = this.range.from; opt; opt = opt.nextSibling as (HTMLElement | null), i++) { if (opt.nodeName != "LI" || !opt.id) { i-- // A section header } else if (i == selected) { if (!opt.hasAttribute("aria-selected")) { opt.setAttribute("aria-selected", "true") set = opt } } else { if (opt.hasAttribute("aria-selected")) opt.removeAttribute("aria-selected") } } if (set) scrollIntoView(this.list, set) return set } measureInfo() { let sel = this.dom.querySelector("[aria-selected]") as HTMLElement | null if (!sel || !this.info) return null let listRect = this.dom.getBoundingClientRect() let infoRect = this.info!.getBoundingClientRect() let selRect = sel.getBoundingClientRect() let space = this.space if (!space) { let win = this.dom.ownerDocument.defaultView || window space = {left: 0, top: 0, right: win.innerWidth, bottom: win.innerHeight} } if (selRect.top > Math.min(space.bottom, listRect.bottom) - 10 || selRect.bottom < Math.max(space.top, listRect.top) + 10) return null return (this.view.state.facet(completionConfig).positionInfo as any)( this.view, listRect, selRect, infoRect, space, this.dom) } placeInfo(pos: {style?: string, class?: string} | null) { if (this.info) { if (pos) { if (pos.style) this.info.style.cssText = pos.style this.info.className = "cm-tooltip cm-completionInfo " + (pos.class || "") } else { this.info.style.cssText = "top: -1e6px" } } } createListBox(options: readonly Option[], id: string, range: {from: number, to: number}) { const ul = document.createElement("ul") ul.id = id ul.setAttribute("role", "listbox") ul.setAttribute("aria-expanded", "true") ul.setAttribute("aria-label", this.view.state.phrase("Completions")) let curSection: string | null = null for (let i = range.from; i < range.to; i++) { let {completion, match} = options[i], {section} = completion if (section) { let name = typeof section == "string" ? section : section.name if (name != curSection && (i > range.from || range.from == 0)) { curSection = name if (typeof section != "string" && section.header) { ul.appendChild(section.header(section)) } else { let header = ul.appendChild(document.createElement("completion-section")) header.textContent = name } } } const li = ul.appendChild(document.createElement("li")) li.id = id + "-" + i li.setAttribute("role", "option") let cls = this.optionClass(completion) if (cls) li.className = cls for (let source of this.optionContent) { let node = source(completion, this.view.state, this.view, match) if (node) li.appendChild(node) } } if (range.from) ul.classList.add("cm-completionListIncompleteTop") if (range.to < options.length) ul.classList.add("cm-completionListIncompleteBottom") return ul } destroyInfo() { if (this.info) { if (this.infoDestroy) this.infoDestroy() this.info.remove() this.info = null } } destroy() { this.destroyInfo() } } export function completionTooltip(stateField: StateField, applyCompletion: (view: EditorView, option: Option) => void) { return (view: EditorView): TooltipView => new CompletionTooltip(view, stateField, applyCompletion) } function scrollIntoView(container: HTMLElement, element: HTMLElement) { let parent = container.getBoundingClientRect() let self = element.getBoundingClientRect() let scaleY = parent.height / container.offsetHeight if (self.top < parent.top) container.scrollTop -= (parent.top - self.top) / scaleY else if (self.bottom > parent.bottom) container.scrollTop += (self.bottom - parent.bottom) / scaleY } autocomplete-6.11.1/src/view.ts000066400000000000000000000211411453106274700164220ustar00rootroot00000000000000import {EditorView, Command, ViewPlugin, PluginValue, ViewUpdate, logException, getTooltip, TooltipView} from "@codemirror/view" import {Transaction} from "@codemirror/state" import {completionState, setSelectedEffect, setActiveEffect, State, ActiveSource, ActiveResult, getUserEvent, applyCompletion} from "./state" import {completionConfig} from "./config" import {cur, CompletionResult, CompletionContext, startCompletionEffect, closeCompletionEffect} from "./completion" /// Returns a command that moves the completion selection forward or /// backward by the given amount. export function moveCompletionSelection(forward: boolean, by: "option" | "page" = "option"): Command { return (view: EditorView) => { let cState = view.state.field(completionState, false) if (!cState || !cState.open || cState.open.disabled || Date.now() - cState.open.timestamp < view.state.facet(completionConfig).interactionDelay) return false let step = 1, tooltip: TooltipView | null if (by == "page" && (tooltip = getTooltip(view, cState.open.tooltip))) step = Math.max(2, Math.floor(tooltip.dom.offsetHeight / (tooltip.dom.querySelector("li") as HTMLElement).offsetHeight) - 1) let {length} = cState.open.options let selected = cState.open.selected > -1 ? cState.open.selected + step * (forward ? 1 : -1) : forward ? 0 : length - 1 if (selected < 0) selected = by == "page" ? 0 : length - 1 else if (selected >= length) selected = by == "page" ? length - 1 : 0 view.dispatch({effects: setSelectedEffect.of(selected)}) return true } } /// Accept the current completion. export const acceptCompletion: Command = (view: EditorView) => { let cState = view.state.field(completionState, false) if (view.state.readOnly || !cState || !cState.open || cState.open.selected < 0 || cState.open.disabled || Date.now() - cState.open.timestamp < view.state.facet(completionConfig).interactionDelay) return false return applyCompletion(view, cState.open.options[cState.open.selected]) return true } /// Explicitly start autocompletion. export const startCompletion: Command = (view: EditorView) => { let cState = view.state.field(completionState, false) if (!cState) return false view.dispatch({effects: startCompletionEffect.of(true)}) return true } /// Close the currently active completion. export const closeCompletion: Command = (view: EditorView) => { let cState = view.state.field(completionState, false) if (!cState || !cState.active.some(a => a.state != State.Inactive)) return false view.dispatch({effects: closeCompletionEffect.of(null)}) return true } class RunningQuery { time = Date.now() updates: Transaction[] = [] // Note that 'undefined' means 'not done yet', whereas 'null' means // 'query returned null'. done: undefined | CompletionResult | null = undefined constructor(readonly active: ActiveSource, readonly context: CompletionContext) {} } const MaxUpdateCount = 50, MinAbortTime = 1000 const enum CompositionState { None, Started, Changed, ChangedAndMoved } export const completionPlugin = ViewPlugin.fromClass(class implements PluginValue { debounceUpdate = -1 running: RunningQuery[] = [] debounceAccept = -1 composing = CompositionState.None constructor(readonly view: EditorView) { for (let active of view.state.field(completionState).active) if (active.state == State.Pending) this.startQuery(active) } update(update: ViewUpdate) { let cState = update.state.field(completionState) if (!update.selectionSet && !update.docChanged && update.startState.field(completionState) == cState) return let doesReset = update.transactions.some(tr => { return (tr.selection || tr.docChanged) && !getUserEvent(tr) }) for (let i = 0; i < this.running.length; i++) { let query = this.running[i] if (doesReset || query.updates.length + update.transactions.length > MaxUpdateCount && Date.now() - query.time > MinAbortTime) { for (let handler of query.context.abortListeners!) { try { handler() } catch(e) { logException(this.view.state, e) } } query.context.abortListeners = null this.running.splice(i--, 1) } else { query.updates.push(...update.transactions) } } if (this.debounceUpdate > -1) clearTimeout(this.debounceUpdate) this.debounceUpdate = cState.active.some(a => a.state == State.Pending && !this.running.some(q => q.active.source == a.source)) ? setTimeout(() => this.startUpdate(), 50) : -1 if (this.composing != CompositionState.None) for (let tr of update.transactions) { if (getUserEvent(tr) == "input") this.composing = CompositionState.Changed else if (this.composing == CompositionState.Changed && tr.selection) this.composing = CompositionState.ChangedAndMoved } } startUpdate() { this.debounceUpdate = -1 let {state} = this.view, cState = state.field(completionState) for (let active of cState.active) { if (active.state == State.Pending && !this.running.some(r => r.active.source == active.source)) this.startQuery(active) } } startQuery(active: ActiveSource) { let {state} = this.view, pos = cur(state) let context = new CompletionContext(state, pos, active.explicitPos == pos) let pending = new RunningQuery(active, context) this.running.push(pending) Promise.resolve(active.source(context)).then(result => { if (!pending.context.aborted) { pending.done = result || null this.scheduleAccept() } }, err => { this.view.dispatch({effects: closeCompletionEffect.of(null)}) logException(this.view.state, err) }) } scheduleAccept() { if (this.running.every(q => q.done !== undefined)) this.accept() else if (this.debounceAccept < 0) this.debounceAccept = setTimeout(() => this.accept(), this.view.state.facet(completionConfig).updateSyncTime) } // For each finished query in this.running, try to create a result // or, if appropriate, restart the query. accept() { if (this.debounceAccept > -1) clearTimeout(this.debounceAccept) this.debounceAccept = -1 let updated: ActiveSource[] = [] let conf = this.view.state.facet(completionConfig) for (let i = 0; i < this.running.length; i++) { let query = this.running[i] if (query.done === undefined) continue this.running.splice(i--, 1) if (query.done) { let active: ActiveSource = new ActiveResult( query.active.source, query.active.explicitPos, query.done, query.done.from, query.done.to ?? cur(query.updates.length ? query.updates[0].startState : this.view.state)) // Replay the transactions that happened since the start of // the request and see if that preserves the result for (let tr of query.updates) active = active.update(tr, conf) if (active.hasResult()) { updated.push(active) continue } } let current = this.view.state.field(completionState).active.find(a => a.source == query.active.source) if (current && current.state == State.Pending) { if (query.done == null) { // Explicitly failed. Should clear the pending status if it // hasn't been re-set in the meantime. let active = new ActiveSource(query.active.source, State.Inactive) for (let tr of query.updates) active = active.update(tr, conf) if (active.state != State.Pending) updated.push(active) } else { // Cleared by subsequent transactions. Restart. this.startQuery(current) } } } if (updated.length) this.view.dispatch({effects: setActiveEffect.of(updated)}) } }, { eventHandlers: { blur(event) { let state = this.view.state.field(completionState, false) if (state && state.tooltip && this.view.state.facet(completionConfig).closeOnBlur) { let dialog = state.open && getTooltip(this.view, state.open.tooltip) if (!dialog || !dialog.dom.contains(event.relatedTarget as HTMLElement)) this.view.dispatch({effects: closeCompletionEffect.of(null)}) } }, compositionstart() { this.composing = CompositionState.Started }, compositionend() { if (this.composing == CompositionState.ChangedAndMoved) { // Safari fires compositionend events synchronously, possibly // from inside an update, so dispatch asynchronously to avoid reentrancy setTimeout(() => this.view.dispatch({effects: startCompletionEffect.of(false)}), 20) } this.composing = CompositionState.None } } }) autocomplete-6.11.1/src/word.ts000066400000000000000000000054001453106274700164230ustar00rootroot00000000000000import {Text} from "@codemirror/state" import {Completion, CompletionSource} from "./completion" const enum C { Range = 50000, MinCacheLen = 1000, MaxList = 2000 } function wordRE(wordChars: string) { let escaped = wordChars.replace(/[\]\-\\]/g, "\\$&") try { return new RegExp(`[\\p{Alphabetic}\\p{Number}_${escaped}]+`, "ug") } catch { return new RegExp(`[\w${escaped}]`, "g") } } function mapRE(re: RegExp, f: (source: string) => string) { return new RegExp(f(re.source), re.unicode ? "u" : "") } const wordCaches: {[wordChars: string]: WeakMap} = Object.create(null) function wordCache(wordChars: string) { return wordCaches[wordChars] || (wordCaches[wordChars] = new WeakMap) } function storeWords(doc: Text, wordRE: RegExp, result: Completion[], seen: {[word: string]: boolean}, ignoreAt: number) { for (let lines = doc.iterLines(), pos = 0; !lines.next().done;) { let {value} = lines, m wordRE.lastIndex = 0 while (m = wordRE.exec(value)) { if (!seen[m[0]] && pos + m.index != ignoreAt) { result.push({type: "text", label: m[0]}) seen[m[0]] = true if (result.length >= C.MaxList) return } } pos += value.length + 1 } } function collectWords(doc: Text, cache: WeakMap, wordRE: RegExp, to: number, ignoreAt: number) { let big = doc.length >= C.MinCacheLen let cached = big && cache.get(doc) if (cached) return cached let result: Completion[] = [], seen: {[word: string]: boolean} = Object.create(null) if (doc.children) { let pos = 0 for (let ch of doc.children) { if (ch.length >= C.MinCacheLen) { for (let c of collectWords(ch, cache, wordRE, to - pos, ignoreAt - pos)) { if (!seen[c.label]) { seen[c.label] = true result.push(c) } } } else { storeWords(ch, wordRE, result, seen, ignoreAt - pos) } pos += ch.length + 1 } } else { storeWords(doc, wordRE, result, seen, ignoreAt) } if (big && result.length < C.MaxList) cache.set(doc, result) return result } /// A completion source that will scan the document for words (using a /// [character categorizer](#state.EditorState.charCategorizer)), and /// return those as completions. export const completeAnyWord: CompletionSource = context => { let wordChars = context.state.languageDataAt("wordChars", context.pos).join("") let re = wordRE(wordChars) let token = context.matchBefore(mapRE(re, s => s + "$")) if (!token && !context.explicit) return null let from = token ? token.from : context.pos let options = collectWords(context.state.doc, wordCache(wordChars), re, C.Range, from) return {from, options, validFor: mapRE(re, s => "^" + s)} } autocomplete-6.11.1/test/000077500000000000000000000000001453106274700152715ustar00rootroot00000000000000autocomplete-6.11.1/test/webtest-autocomplete.ts000066400000000000000000000341451453106274700220240ustar00rootroot00000000000000import {EditorView} from "@codemirror/view" import {EditorState, EditorSelection} from "@codemirror/state" import {CompletionSource, autocompletion, CompletionContext, startCompletion, currentCompletions, completionStatus, completeFromList, acceptCompletion} from "@codemirror/autocomplete" import ist from "ist" const Timeout = 1000, Chunk = 15 type Sync = (get: (state: EditorState) => T, value: T) => Promise type TestSpec = { doc?: string, selection?: number | EditorSelection, sources: readonly CompletionSource[] } class Runner { tests: {name: string, spec: TestSpec, f: (view: EditorView, sync: Sync) => Promise}[] = [] test(name: string, spec: TestSpec, f: (view: EditorView, sync: Sync) => Promise) { this.tests.push({name, spec, f}) } options(name: string, doc: string, sources: readonly CompletionSource[], list: string) { this.test(name, {doc, sources}, (view, sync) => { startCompletion(view) return sync(options, list) }) } runTest(name: string, spec: TestSpec, f: (view: EditorView, sync: Sync) => Promise) { let syncing: {get: (state: EditorState) => any, value: any, resolve: () => void} | null = null let selection = spec.selection == null ? EditorSelection.single((spec.doc || "").length) : typeof spec.selection == "number" ? EditorSelection.single(spec.selection) : spec.selection let view = new EditorView({ state: EditorState.create({ doc: spec.doc, selection, extensions: [autocompletion({override: spec.sources, interactionDelay: 0, updateSyncTime: 40}), EditorState.allowMultipleSelections.of(true)] }), parent: document.querySelector("#workspace")! as HTMLElement, dispatchTransactions: trs => { if (syncing && syncing.get(trs[trs.length - 1].state) === syncing.value) { syncing.resolve() syncing = null } view.update(trs) } }) let sync = (get: (state: EditorState) => any, value: any) => new Promise((resolve, reject) => { if (syncing) throw new Error("Overlapping syncs") if (get(view.state) === value) return resolve() let mine = syncing = {get, value, resolve} setTimeout(() => { if (syncing == mine) reject(new Error(`${name}: Failed to sync: ${get(view.state)} !== ${value}\n`)) }, Timeout) }) return {view, promise: f(view, sync)} } async finish(filter?: string) { let tests = this.tests if (filter) tests = tests.filter(t => t.name.indexOf(filter) > -1) for (let from = 0; from < tests.length; from += Chunk) { let active = tests.slice(from, Math.min(tests.length, from + Chunk)).map(t => this.runTest(t.name, t.spec, t.f)) let cleanup = () => { for (let {view} of active) view.destroy() } await Promise.all(active.map(t => t.promise)).then(cleanup, err => { cleanup(); throw err }) } } } function from(list: string): CompletionSource { return cx => { let word = cx.matchBefore(/\w+$/) if (!word && !cx.explicit) return null return {from: word ? word.from : cx.pos, options: list.split(" ").map(w => ({label: w})), validFor: /^\w*/} } } function tagged(validFor: boolean): CompletionSource { return cx => { let word = cx.matchBefore(/\w+$/) return {from: word ? word.from : cx.pos, options: [{label: "tag" + cx.pos}], validFor: validFor ? /^\w*/ : undefined} } } function sleep(delay: number) { return new Promise(resolve => setTimeout(() => resolve(undefined), delay)) } function slow(c: CompletionSource, delay: number): CompletionSource { return (cx: CompletionContext) => new Promise(resolve => setTimeout(() => resolve(c(cx)), delay)) } function once(c: CompletionSource): CompletionSource { let done = false return (cx: CompletionContext) => { if (done) throw new Error("Used 'once' completer multiple times") done = true return c(cx) } } function options(s: EditorState) { return currentCompletions(s).map(c => / /.test(c.label) ? JSON.stringify(c.label) : c.label).join(" ") } function type(view: EditorView, text: string) { let cur = view.state.selection.main.head view.dispatch({changes: {from: cur, insert: text}, selection: {anchor: cur + text.length}, userEvent: "input.type"}) } function del(view: EditorView) { let cur = view.state.selection.main.head view.dispatch({changes: {from: cur - 1, to: cur}, userEvent: "delete.backward"}) } const words = "one onetwothree OneTwoThree two three" describe("autocomplete", () => { // Putting all tests together in a single `it` to allow them to run // concurrently. it("works", function() { this.timeout(5000) let run = new Runner run.options("prefers by-word matches", "ott", [from(words)], "OneTwoThree onetwothree") run.options("can merge multiple sources", "one", [from(words), from("onet bonae")], "one onet onetwothree OneTwoThree bonae") run.options("only shows prefix matches for single-letter queries", "t", [from(words)], "three two") run.options("doesn't allow split matches for two-letter queries", "wr", [from(words)], "") run.options("prefers case-matched completions", "eTw", [from(words)], "OneTwoThree onetwothree") run.options("allows everything for empty patterns", "", [from("a b foo")], "a b foo") run.options("sorts alphabetically when score is equal", "a", [from("ac ab acc")], "ab ac acc") run.options("removes duplicate options", "t", [from("two"), from("two three")], "three two") run.options("handles all-uppercase words", "sel", [from("SCOPE_CATALOG SELECT SELECTIVE")], "SELECT SELECTIVE SCOPE_CATALOG") run.options("penalizes by-word matches with gaps", "abc", [from("xabc aVeryBigCar")], "xabc aVeryBigCar") run.options("prefers shorter options", "hair", [ completeFromList(["aVerySmallChair", "Hairstyle", "chair", "BigChair"]) ], "Hairstyle chair BigChair aVerySmallChair") run.test("will eagerly populate the result list when a source is slow", { doc: "on", sources: [from("one two"), slow(from("ono"), 100)] }, async (view, sync) => { startCompletion(view) await sync(options, "one") await sync(options, "one ono") }) run.test("starts completion on input", {sources: [from("one two")]}, async (view, sync) => { type(view, "o") await sync(options, "one") }) run.test("further narrows completions on input", {sources: [once(from("one okay ono"))]}, async (view, sync) => { type(view, "o") await sync(options, "okay one ono") type(view, "n") await sync(options, "one ono") type(view, "e") await sync(options, "one") type(view, "k") await sync(options, "") }) run.test("doesn't abort on backspace", {sources: [once(from("one okay")), once(from("ohai"))]}, async (view, sync) => { type(view, "on") await sync(options, "one") del(view) await sync(options, "ohai okay one") del(view) await sync(options, "") }) run.test("can backspace out entire word when explicit", {sources: [from("one two")]}, async (view, sync) => { startCompletion(view) await sync(options, "one two") type(view, "o") await sync(options, "one") del(view) await sync(options, "one two") }) run.test("stops explicit completion on non-spanning input", {sources: [from("one two")]}, async (view, sync) => { startCompletion(view) await sync(options, "one two") type(view, "o") await sync(options, "one") type(view, " ") await sync(options, "") del(view) await sync(options, "") }) run.test("stops explicit completion when backspacing past start", { doc: "foo.o", sources: [from("one two")] }, async (view, sync) => { startCompletion(view) await sync(options, "one") del(view) await sync(options, "one two") del(view) await sync(options, "") }) run.test("stops explicit completions for non-matching input", {sources: [from("one")]}, async (view, sync) => { startCompletion(view) await sync(options, "one") type(view, "x") await sync(options, "") del(view) await sync(options, "") }) run.test("resets selection after refinement", { sources: [once(from("primitive-classnames print proxy"))] }, async (view, sync) => { type(view, "p") await sync(options, "primitive-classnames print proxy") type(view, "rin") await sync(options, "print primitive-classnames") ist(view.dom.querySelector("[aria-selected]")?.textContent, "print") }) run.test("calls sources again when necessary", {sources: [tagged(true)]}, async (view, sync) => { type(view, "t") await sync(options, "tag1") type(view, " t") await sync(options, "tag3") }) run.test("always calls span-less sources", {sources: [tagged(false)]}, async (view, sync) => { startCompletion(view) await sync(options, "tag0") type(view, "ta") await sync(options, "tag2") del(view) await sync(options, "tag1") del(view) await sync(options, "tag0") }) run.test("adjust completions when changes happen during query", { sources: [slow(once(from("one ok")), 100)] }, async (view, sync) => { type(view, "o") await sleep(80) type(view, "n") await sync(options, "one") }) run.test("doesn't cancel completions when deleting before they finish", { sources: [slow(tagged(false), 80)] }, async (view, sync) => { type(view, "ta") await sleep(80) del(view) await sync(options, "tag1") }) run.test("preserves the dialog on irrelevant changes", { sources: [from("one two")], doc: "woo o" }, async (view, sync) => { startCompletion(view) await sync(options, "one") let dialog = view.dom.querySelector(".cm-tooltip") ist(dialog) view.dispatch({changes: {from: 0, insert: "!"}}) ist(view.dom.querySelector(".cm-tooltip"), dialog) }) run.test("replaces entire selected ranges", { sources: [from("one hey")], doc: "hello world", selection: EditorSelection.single(1, 5) }, async (view, sync) => { startCompletion(view) await sync(options, "hey") acceptCompletion(view) ist(view.state.doc.toString(), "hey world") }) run.test("replaces inverted ranges", { sources: [from("one hey")], doc: "hello world", selection: EditorSelection.single(5, 1) }, async (view, sync) => { startCompletion(view) await sync(options, "hey") acceptCompletion(view) ist(view.state.doc.toString(), "hey world") }) run.test("can cover range beyond cursor", { sources: [cx => ({from: 0, to: 4, options: [{label: "brrrr"}]})], doc: "brrr" }, async (view, sync) => { startCompletion(view) await sync(options, "brrrr") acceptCompletion(view) ist(view.state.doc.toString(), "brrrr") }) run.test("complete from list", {sources: [once(completeFromList(["one", "two", "three"]))], doc: "t"}, async (view, sync) => { startCompletion(view) await sync(options, "three two") type(view, "h") await sync(options, "three") del(view) await sync(options, "three two") del(view) await sync(options, "one three two") }) run.test("complete from nonalphabetic list", { sources: [completeFromList(["$foo.bar", "$baz.boop", "$foo.quux"])] }, async (view, sync) => { type(view, "x") await sync(v => completionStatus(v), null) type(view, "$") await sync(options, "$baz.boop $foo.bar $foo.quux") type(view, "foo.b") await sync(options, "$foo.bar") }) let events: string[] = [] run.test("calls abort handlers", { sources: [async cx => { events.push("start " + cx.aborted) cx.addEventListener("abort", () => events.push("aborted")) await sleep(50) events.push("fin " + cx.aborted) return from("one two")(cx) }], doc: "one two\nthree four " }, async (view) => { startCompletion(view) await sleep(80) view.dispatch({selection: {anchor: 1}}) await sleep(80) ist(events.join(", "), "start false, aborted, fin true") }) run.test("supports unfitered completions", { sources: [completeFromList(["one", "two"]), cx => ({from: cx.pos, options: [{label: "ok"}, {label: "hah"}], filter: false})], doc: "o" }, async (view, sync) => { startCompletion(view) await sync(options, "ok hah one") }) run.test("will complete for multiple cursors", { sources: [from("okay")], doc: "o\no", selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(3)]) }, async (view, sync) => { startCompletion(view) await sync(options, "okay") await sleep(80) acceptCompletion(view) ist(view.state.doc.toString(), "okay\nokay") }) run.test("will not complete for multiple cursors if prefix doesn't match", { sources: [from("okay allo")], doc: "o\na", selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(3)]) }, async (view, sync) => { startCompletion(view) await sync(options, "okay") acceptCompletion(view) ist(view.state.doc.toString(), "okay\na") }) run.test("can synchronously update results", {sources: [cx => ({ options: [{label: "a"}, {label: "aha"}], from: 0, update: (r, from, to) => ({options: r.options.filter(o => o.label.length > 1), from: r.from}) })]}, async (view, sync) => { type(view, "a") await sync(options, "a aha") type(view, "h") ist(options(view.state), "aha") }) run.test("preserves completion position when changes happen", { sources: [from("pow")], doc: "\n\n", selection: 2 }, async (view, sync) => { startCompletion(view) await sync(options, "pow") view.dispatch({changes: {from: 0, insert: "woooooo"}}) acceptCompletion(view) ist(view.state.doc.toString(), "woooooo\n\npow") }) return run.finish() }) })