├── .node-version ├── .remarkrc.yaml ├── .prettierrc.yaml ├── .gitignore ├── .eslintrc.yaml ├── netlify.toml ├── .editorconfig ├── src ├── codemirror-languageservice.ts ├── types.ts ├── text-edit.ts ├── hover-tooltip.ts ├── markup-content.ts ├── text-document.ts ├── lint.ts └── completion.ts ├── tsconfig.json ├── tsconfig.build.json ├── test ├── utils.ts ├── text-document.test.ts ├── text-edit.test.ts ├── hover-tooltip.test.ts ├── markup-content.test.ts ├── lint.test.ts └── completion.test.ts ├── example ├── style.css ├── json.html ├── typescript.html ├── index.html └── scripts │ ├── json.ts │ ├── index.ts │ └── typescript.ts ├── vite.config.ts ├── LICENSE.md ├── .github └── workflows │ └── ci.yaml ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.remarkrc.yaml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - remark-preset-remcohaszing 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | proseWrap: always 2 | semi: false 3 | singleQuote: true 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __screenshots__/ 2 | build/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | *.tsbuildinfo 7 | *.tgz 8 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: remcohaszing 3 | rules: 4 | '@typescript-eslint/no-invalid-void-type': off 5 | '@typescript-eslint/no-namespace': off 6 | '@typescript-eslint/no-redeclare': off 7 | import/no-extraneous-dependencies: off 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = 'build/' 3 | command = 'npx vite build' 4 | 5 | [[headers]] 6 | for = '/*' 7 | [headers.values] 8 | Content-Security-Policy = "default-src 'self'; connect-src *; img-src 'self' data:; style-src 'self' 'unsafe-inline'" 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 100 10 | trim_trailing_whitespace = true 11 | 12 | [COMMIT_EDITMSG] 13 | max_line_length = 72 14 | -------------------------------------------------------------------------------- /src/codemirror-languageservice.ts: -------------------------------------------------------------------------------- 1 | export { createCompletionSource } from './completion.js' 2 | export { createHoverTooltipSource } from './hover-tooltip.js' 3 | export { createLintSource } from './lint.js' 4 | export { getTextDocument, textDocument } from './text-document.js' 5 | export { dispatchTextEdits } from './text-edit.js' 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["src"], 3 | "references": [{ "path": "./tsconfig.build.json" }], 4 | "compilerOptions": { 5 | "module": "node16", 6 | "moduleDetection": "force", 7 | "noEmit": true, 8 | "resolveJsonModule": true, 9 | "strict": true, 10 | "target": "es2022", 11 | "types": [] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "module": "node16", 8 | "moduleDetection": "force", 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "es2022", 14 | "types": [] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Either a {@link PromiseLike} or the synchronous value. 3 | * 4 | * @template T 5 | * The type to make promise-like. 6 | */ 7 | export type Promisable = PromiseLike | T 8 | 9 | /** 10 | * A {@link Promisable} variant of the given type or null or undefined. 11 | * 12 | * @template T 13 | * The regular result type to expect. 14 | */ 15 | export type LSPResult = Promisable 16 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { toDom } from 'hast-util-to-dom' 2 | import { fromMarkdown } from 'mdast-util-from-markdown' 3 | import { toHast } from 'mdast-util-to-hast' 4 | 5 | /** 6 | * Convert markdown content to a DOM node. 7 | * 8 | * @param markdown 9 | * The markdown content. 10 | * @returns 11 | * The DOM node that represents the markdown. 12 | */ 13 | export function markdownToDom(markdown: string): Node { 14 | const mdast = fromMarkdown(markdown) 15 | const hast = toHast(mdast) 16 | return toDom(hast, { fragment: true }) 17 | } 18 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: system-ui; 3 | margin: 0; 4 | } 5 | 6 | nav { 7 | background: #043d20; 8 | display: flex; 9 | justify-content: space-between; 10 | padding: 0.5rem; 11 | text-align: right; 12 | 13 | & a { 14 | color: #92b5ff; 15 | padding: 0 0.5rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | text-align: center; 21 | margin: 1rem; 22 | } 23 | 24 | .cm-editor { 25 | margin: 0.5rem; 26 | } 27 | 28 | .cm-lintRange-deprecated { 29 | background-image: none !important; 30 | text-decoration: line-through; 31 | } 32 | 33 | .cm-lintRange-unnecessary { 34 | background-repeat: no-repeat !important; 35 | opacity: 0.4; 36 | } 37 | -------------------------------------------------------------------------------- /example/json.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CodeMirror language service JSON example 8 | 9 | 10 | 11 | 22 |
23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /example/typescript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CodeMirror language service MDX example 8 | 9 | 10 | 11 | 22 |
23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | 3 | import { defineConfig } from 'vitest/config' 4 | 5 | function resolve(path: string): string { 6 | return fileURLToPath(new URL(path, import.meta.url)) 7 | } 8 | 9 | export default defineConfig({ 10 | root: resolve('example'), 11 | build: { 12 | emptyOutDir: true, 13 | outDir: resolve('build'), 14 | rollupOptions: { 15 | input: [ 16 | resolve('example/index.html'), 17 | resolve('example/json.html'), 18 | resolve('example/typescript.html') 19 | ] 20 | } 21 | }, 22 | resolve: { 23 | alias: { 24 | 'codemirror-languageservice': resolve('src/codemirror-languageservice') 25 | } 26 | }, 27 | test: { 28 | root: resolve('./'), 29 | browser: { 30 | enabled: true, 31 | headless: true, 32 | name: 'chromium', 33 | provider: 'playwright' 34 | }, 35 | coverage: { 36 | enabled: true, 37 | include: ['src'], 38 | provider: 'istanbul' 39 | } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CodeMirror language service example 8 | 9 | 10 | 11 | 22 |
23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2024 Remco Haszing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the “Software”), to deal in the Software without restriction, 7 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 15 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 17 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/text-edit.ts: -------------------------------------------------------------------------------- 1 | import { type ChangeSpec, type Text } from '@codemirror/state' 2 | import { type EditorView } from '@codemirror/view' 3 | import { type Position, type TextEdit } from 'vscode-languageserver-protocol' 4 | 5 | /** 6 | * Get the character offset of a CodeMirror text document from an LSP position. 7 | * 8 | * @param doc 9 | * The CodeMirror text document for which to get the offset. 10 | * @param position 11 | * The LSP position to get the offset of. 12 | * @returns 13 | * The offset 14 | */ 15 | function getOffset(doc: Text, position: Position): number { 16 | const line = doc.line(position.line + 1) 17 | return line.from + Math.min(position.character, line.length) 18 | } 19 | 20 | /** 21 | * Apply LSP text edits to an CodeMirror {@link EditorView}. 22 | * 23 | * @param view 24 | * The view to dispatch the changes to. 25 | * @param edits 26 | * The edits that should be applied. 27 | */ 28 | export function dispatchTextEdits(view: EditorView, edits: Iterable): undefined { 29 | const changes: ChangeSpec[] = [] 30 | const { doc } = view.state 31 | 32 | for (const edit of edits) { 33 | changes.push({ 34 | from: getOffset(doc, edit.range.start), 35 | to: getOffset(doc, edit.range.end), 36 | insert: edit.newText 37 | }) 38 | } 39 | 40 | view.dispatch({ changes }) 41 | } 42 | -------------------------------------------------------------------------------- /src/hover-tooltip.ts: -------------------------------------------------------------------------------- 1 | import { type HoverTooltipSource } from '@codemirror/view' 2 | import { type Hover, type Position } from 'vscode-languageserver-protocol' 3 | import { type TextDocument } from 'vscode-languageserver-textdocument' 4 | 5 | import { fromMarkupContent } from './markup-content.js' 6 | import { getTextDocument } from './text-document.js' 7 | import { type LSPResult } from './types.js' 8 | 9 | export declare namespace createHoverTooltipSource { 10 | interface Options extends fromMarkupContent.Options { 11 | /** 12 | * Provide LSP hover info. 13 | * 14 | * @param textDocument 15 | * The text document for which to provide hover info. 16 | * @param position 17 | * The position for which to provide hover info. 18 | * @returns 19 | * The hover info for the given document and position. 20 | */ 21 | doHover: (textDocument: TextDocument, position: Position) => LSPResult 22 | } 23 | } 24 | 25 | /** 26 | * Create an LSP based hover tooltip provider. 27 | * 28 | * @param options 29 | * Options to configure the hover tooltips. 30 | * @returns 31 | * A CodeMirror hover tooltip source that uses LSP based hover information. 32 | */ 33 | export function createHoverTooltipSource( 34 | options: createHoverTooltipSource.Options 35 | ): HoverTooltipSource { 36 | return async (view, pos) => { 37 | const textDocument = getTextDocument(view.state) 38 | 39 | const info = await options.doHover(textDocument, textDocument.positionAt(pos)) 40 | 41 | if (!info) { 42 | return null 43 | } 44 | 45 | if (textDocument.version !== getTextDocument(view.state).version) { 46 | return null 47 | } 48 | 49 | let start = pos 50 | let end: number | undefined 51 | const { contents, range } = info 52 | 53 | if (range) { 54 | start = textDocument.offsetAt(range.start) 55 | end = textDocument.offsetAt(range.end) 56 | } 57 | 58 | return { 59 | pos: start, 60 | end, 61 | create: () => ({ dom: fromMarkupContent(contents, document.createElement('div'), options) }) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | tags: ['*'] 8 | 9 | jobs: 10 | eslint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | - run: npm ci 18 | - run: npx eslint . 19 | 20 | pack: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 22 27 | - run: npm ci 28 | - run: npm pack 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: package 32 | path: '*.tgz' 33 | 34 | prettier: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 22 41 | - run: npm ci 42 | - run: npx prettier --check . 43 | 44 | remark: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-node@v4 49 | with: 50 | node-version: 22 51 | - run: npm ci 52 | - run: npx remark --frail . 53 | 54 | test: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | - uses: actions/setup-node@v4 59 | with: 60 | node-version: 22 61 | - run: npm ci 62 | - run: npx playwright install --with-deps chromium 63 | - run: npm test 64 | - uses: codecov/codecov-action@v4 65 | 66 | release: 67 | runs-on: ubuntu-latest 68 | needs: 69 | - eslint 70 | - pack 71 | - prettier 72 | - remark 73 | - test 74 | if: startsWith(github.ref, 'refs/tags/') 75 | permissions: 76 | id-token: write 77 | steps: 78 | - uses: actions/setup-node@v4 79 | with: 80 | node-version: 22 81 | registry-url: https://registry.npmjs.org 82 | - uses: actions/download-artifact@v4 83 | with: { name: package } 84 | - run: npm publish *.tgz --provenance --access public 85 | env: 86 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 87 | -------------------------------------------------------------------------------- /test/text-document.test.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@codemirror/lang-json' 2 | import { StateEffect } from '@codemirror/state' 3 | import { EditorView } from '@codemirror/view' 4 | import { getTextDocument, textDocument } from 'codemirror-languageservice' 5 | import { expect, test } from 'vitest' 6 | 7 | test('inmemory://', () => { 8 | const view = new EditorView({ 9 | doc: 'Initial text\n', 10 | extensions: [textDocument()] 11 | }) 12 | 13 | const document = getTextDocument(view.state) 14 | expect(document.uri).toBe('inmemory://1') 15 | expect(document.version).toBe(0) 16 | expect(document.getText()).toBe('Initial text\n') 17 | }) 18 | 19 | test('uri', () => { 20 | const view = new EditorView({ 21 | doc: 'Initial text\n', 22 | extensions: [textDocument('file:///original.txt')] 23 | }) 24 | 25 | const original = getTextDocument(view.state) 26 | expect(original.uri).toBe('file:///original.txt') 27 | expect(original.version).toBe(0) 28 | expect(original.languageId).toBe('plaintext') 29 | expect(original.getText()).toBe('Initial text\n') 30 | 31 | view.dispatch({ effects: StateEffect.reconfigure.of(textDocument('file:///updated.txt')) }) 32 | 33 | const updated = getTextDocument(view.state) 34 | expect(updated.uri).toBe('file:///updated.txt') 35 | expect(updated.version).toBe(0) 36 | expect(updated.languageId).toBe('plaintext') 37 | expect(updated.getText()).toBe('Initial text\n') 38 | }) 39 | 40 | test('language ID', () => { 41 | const view = new EditorView({ 42 | doc: '{}\n', 43 | extensions: [textDocument()] 44 | }) 45 | 46 | const original = getTextDocument(view.state) 47 | expect(original.version).toBe(0) 48 | expect(original.languageId).toBe('plaintext') 49 | expect(original.getText()).toBe('{}\n') 50 | 51 | view.dispatch({ effects: StateEffect.appendConfig.of(json()) }) 52 | 53 | const updated = getTextDocument(view.state) 54 | expect(updated.version).toBe(1) 55 | expect(updated.languageId).toBe('json') 56 | expect(updated.getText()).toBe('{}\n') 57 | }) 58 | 59 | test('update reuse', () => { 60 | const view = new EditorView({ 61 | doc: 'Initial text\n', 62 | extensions: [textDocument()] 63 | }) 64 | const original = getTextDocument(view.state) 65 | 66 | view.dispatch({ effects: StateEffect.appendConfig.of([]) }) 67 | 68 | const updated = getTextDocument(view.state) 69 | expect(updated).toBe(original) 70 | }) 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codemirror-languageservice", 3 | "version": "0.2.2", 4 | "description": "Integrate a Language Server Protocol compatible language service into CodeMirror", 5 | "keywords": [ 6 | "code", 7 | "codemirror", 8 | "code-mirror", 9 | "editor", 10 | "languageserver", 11 | "languageservice", 12 | "language-server", 13 | "language-service", 14 | "lsp" 15 | ], 16 | "homepage": "https://codemirror-languageservice.js.org", 17 | "bugs": "https://github.com/remcohaszing/codemirror-languageservice/issues", 18 | "repository": "remcohaszing/codemirror-languageservice", 19 | "funding": "https://github.com/sponsors/remcohaszing", 20 | "license": "MIT", 21 | "author": "Remco Haszing ", 22 | "sideEffects": false, 23 | "type": "module", 24 | "exports": "./dist/codemirror-languageservice.js", 25 | "files": [ 26 | "dist", 27 | "src" 28 | ], 29 | "scripts": { 30 | "prepack": "tsc --build", 31 | "prestart": "tsc --build", 32 | "start": "vite example", 33 | "test": "vitest run" 34 | }, 35 | "dependencies": { 36 | "vscode-languageserver-protocol": "^3.0.0", 37 | "vscode-languageserver-textdocument": "^1.0.0" 38 | }, 39 | "peerDependencies": { 40 | "@codemirror/autocomplete": "^6.0.0", 41 | "@codemirror/language": "^6.0.0", 42 | "@codemirror/lint": "^6.0.0", 43 | "@codemirror/state": "^6.0.0", 44 | "@codemirror/view": "^6.0.0" 45 | }, 46 | "devDependencies": { 47 | "@codemirror/commands": "^6.0.0", 48 | "@codemirror/lang-javascript": "^6.0.0", 49 | "@codemirror/lang-json": "^6.0.0", 50 | "@codemirror/theme-one-dark": "^6.0.0", 51 | "@vitest/browser": "^2.0.0", 52 | "@vitest/coverage-istanbul": "^2.0.0", 53 | "@volar/jsdelivr": "~2.4.0", 54 | "@volar/language-service": "~2.4.0", 55 | "@volar/typescript": "~2.4.0", 56 | "eslint": "^8.0.0", 57 | "eslint-config-remcohaszing": "^10.0.0", 58 | "hast-util-to-dom": "^4.0.0", 59 | "mdast-util-from-markdown": "^2.0.0", 60 | "mdast-util-to-hast": "^13.0.0", 61 | "playwright": "^1.0.0", 62 | "prettier": "^3.0.0", 63 | "remark-cli": "^12.0.0", 64 | "remark-preset-remcohaszing": "^3.0.0", 65 | "typescript": "^5.0.0", 66 | "vite": "^5.0.0", 67 | "vitest": "^2.0.0", 68 | "volar-service-typescript": "0.0.62", 69 | "vscode-json-languageservice": "^5.0.0", 70 | "vscode-languageserver-types": "^3.0.0", 71 | "vscode-uri": "^3.0.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/markup-content.ts: -------------------------------------------------------------------------------- 1 | import { type MarkedString, type MarkupContent } from 'vscode-languageserver-protocol' 2 | 3 | /** 4 | * Process markdown into a DOM. 5 | * 6 | * @param parent 7 | * The container DOM element to append the DOM nodes to. 8 | * @param markdown 9 | * The markdown to process. 10 | * @param options 11 | * Additional options. 12 | */ 13 | function processMarkdown( 14 | parent: ParentNode, 15 | markdown: string, 16 | options: fromMarkupContent.Options 17 | ): undefined { 18 | if (!markdown) { 19 | return 20 | } 21 | 22 | const nodes = options.markdownToDom(markdown) 23 | if (!nodes) { 24 | return 25 | } 26 | 27 | if (typeof nodes !== 'string' && Symbol.iterator in nodes) { 28 | parent.append(...nodes) 29 | } else { 30 | parent.append(nodes) 31 | } 32 | } 33 | 34 | export declare namespace fromMarkupContent { 35 | interface Options { 36 | /** 37 | * Convert a markdown string to DOM. 38 | * 39 | * @param markdown 40 | * The markdown to convert 41 | * @returns 42 | * DOM nodes or text to append to the resulting DOM container. 43 | */ 44 | markdownToDom: ( 45 | markdown: string 46 | ) => Iterable | Node | string | null | undefined | void 47 | } 48 | } 49 | 50 | /** 51 | * Convert LSP markup content or a marked string into a DOM. 52 | * 53 | * @param contents 54 | * The LSP contents to process. 55 | * @param parent 56 | * The container node to append the DOM nodes to. 57 | * @param options 58 | * Additional options. 59 | * @returns 60 | * The parent container. 61 | */ 62 | export function fromMarkupContent( 63 | contents: MarkedString | MarkedString[] | MarkupContent, 64 | parent: Parent, 65 | options: fromMarkupContent.Options 66 | ): Parent { 67 | if (Array.isArray(contents)) { 68 | for (const content of contents) { 69 | fromMarkupContent(content, parent, options) 70 | } 71 | } else if (typeof contents === 'string') { 72 | processMarkdown(parent, contents, options) 73 | } else if ('kind' in contents) { 74 | if (contents.kind === 'markdown') { 75 | processMarkdown(parent, contents.value, options) 76 | } else { 77 | const paragraph = document.createElement('p') 78 | paragraph.append(contents.value) 79 | parent.append(paragraph) 80 | } 81 | } else { 82 | const pre = document.createElement('pre') 83 | const code = document.createElement('code') 84 | code.classList.add(`language-${contents.language}`) 85 | code.append(contents.value) 86 | pre.append(code) 87 | parent.append(pre) 88 | } 89 | 90 | return parent 91 | } 92 | -------------------------------------------------------------------------------- /test/text-edit.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view' 2 | import { dispatchTextEdits } from 'codemirror-languageservice' 3 | import { expect, test } from 'vitest' 4 | 5 | test('single edit', () => { 6 | const view = new EditorView({ 7 | doc: 'Initial text\n' 8 | }) 9 | 10 | dispatchTextEdits(view, [ 11 | { 12 | newText: 'Updated', 13 | range: { 14 | start: { line: 0, character: 0 }, 15 | end: { line: 0, character: 7 } 16 | } 17 | } 18 | ]) 19 | 20 | expect(String(view.state.doc)).toBe('Updated text\n') 21 | }) 22 | 23 | test('multiple edits start to end', () => { 24 | const view = new EditorView({ 25 | doc: 'Initial text\n' 26 | }) 27 | 28 | dispatchTextEdits(view, [ 29 | { 30 | newText: 'Updated', 31 | range: { 32 | start: { line: 0, character: 0 }, 33 | end: { line: 0, character: 7 } 34 | } 35 | }, 36 | { 37 | newText: 'content', 38 | range: { 39 | start: { line: 0, character: 8 }, 40 | end: { line: 0, character: 12 } 41 | } 42 | } 43 | ]) 44 | 45 | expect(String(view.state.doc)).toBe('Updated content\n') 46 | }) 47 | 48 | test('multiple edits end to start', () => { 49 | const view = new EditorView({ 50 | doc: 'Initial text\n' 51 | }) 52 | 53 | dispatchTextEdits(view, [ 54 | { 55 | newText: 'content', 56 | range: { 57 | start: { line: 0, character: 8 }, 58 | end: { line: 0, character: 12 } 59 | } 60 | }, 61 | { 62 | newText: 'Updated', 63 | range: { 64 | start: { line: 0, character: 0 }, 65 | end: { line: 0, character: 7 } 66 | } 67 | } 68 | ]) 69 | 70 | expect(String(view.state.doc)).toBe('Updated content\n') 71 | }) 72 | 73 | test('end character exceeds line', () => { 74 | const view = new EditorView({ 75 | doc: 'line1\nline2\nline3\n' 76 | }) 77 | 78 | dispatchTextEdits(view, [ 79 | { 80 | newText: '|', 81 | range: { 82 | start: { line: 1, character: 0 }, 83 | end: { line: 1, character: 1000 } 84 | } 85 | } 86 | ]) 87 | 88 | expect(String(view.state.doc)).toBe('line1\n|\nline3\n') 89 | }) 90 | 91 | test('end character exceeds line', () => { 92 | const view = new EditorView({ 93 | doc: 'line1\nline2\nline3\n' 94 | }) 95 | 96 | dispatchTextEdits(view, [ 97 | { 98 | newText: '|', 99 | range: { 100 | start: { line: 0, character: 1000 }, 101 | end: { line: 1, character: 0 } 102 | } 103 | } 104 | ]) 105 | 106 | expect(String(view.state.doc)).toBe('line1|line2\nline3\n') 107 | }) 108 | -------------------------------------------------------------------------------- /test/hover-tooltip.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorView, type Tooltip } from '@codemirror/view' 2 | import { createHoverTooltipSource, getTextDocument, textDocument } from 'codemirror-languageservice' 3 | import { expect, test } from 'vitest' 4 | import { type Position } from 'vscode-languageserver-protocol' 5 | import { type TextDocument } from 'vscode-languageserver-textdocument' 6 | 7 | import { markdownToDom } from './utils.js' 8 | 9 | test('hover args', async () => { 10 | let document: TextDocument | undefined 11 | let position: Position | undefined 12 | 13 | const view = new EditorView({ 14 | doc: 'Text\n', 15 | extensions: [textDocument()] 16 | }) 17 | 18 | const hoverTooltipSource = createHoverTooltipSource({ 19 | markdownToDom, 20 | doHover(doc, pos) { 21 | document = doc 22 | position = pos 23 | } 24 | }) 25 | 26 | const tooltip = await hoverTooltipSource(view, 2, 1) 27 | 28 | expect(document).toBe(getTextDocument(view.state)) 29 | expect(position).toStrictEqual({ line: 0, character: 2 }) 30 | expect(tooltip).toBeNull() 31 | }) 32 | 33 | test('ignore outdated document', async () => { 34 | const view = new EditorView({ 35 | doc: 'Text\n', 36 | extensions: [textDocument()] 37 | }) 38 | 39 | const hoverTooltipSource = createHoverTooltipSource({ 40 | markdownToDom, 41 | doHover() { 42 | view.dispatch({ changes: [{ from: 0, to: 4, insert: 'Updated' }] }) 43 | return { 44 | contents: 'Hover content' 45 | } 46 | } 47 | }) 48 | 49 | const tooltip = await hoverTooltipSource(view, 2, 1) 50 | 51 | expect(tooltip).toBeNull() 52 | }) 53 | 54 | test('without range', async () => { 55 | const view = new EditorView({ 56 | doc: 'Text\nText\n', 57 | extensions: [textDocument()] 58 | }) 59 | 60 | const hoverTooltipSource = createHoverTooltipSource({ 61 | markdownToDom, 62 | doHover() { 63 | return { 64 | contents: 'Hover content' 65 | } 66 | } 67 | }) 68 | 69 | const tooltip = await hoverTooltipSource(view, 7, 1) 70 | 71 | expect(tooltip).toStrictEqual({ 72 | pos: 7, 73 | end: undefined, 74 | create: expect.any(Function) 75 | }) 76 | 77 | const content = (tooltip as unknown as Tooltip).create(view) 78 | expect(content.dom).toMatchInlineSnapshot(` 79 |
80 |

81 | Hover content 82 |

83 |
84 | `) 85 | }) 86 | 87 | test('with range', async () => { 88 | const view = new EditorView({ 89 | doc: 'Text\nText\n', 90 | extensions: [textDocument()] 91 | }) 92 | 93 | const hoverTooltipSource = createHoverTooltipSource({ 94 | markdownToDom, 95 | doHover() { 96 | return { 97 | contents: 'Hover content', 98 | range: { 99 | start: { line: 1, character: 0 }, 100 | end: { line: 1, character: 4 } 101 | } 102 | } 103 | } 104 | }) 105 | 106 | const tooltip = await hoverTooltipSource(view, 7, 1) 107 | 108 | expect(tooltip).toStrictEqual({ 109 | pos: 5, 110 | end: 9, 111 | create: expect.any(Function) 112 | }) 113 | 114 | const content = (tooltip as unknown as Tooltip).create(view) 115 | expect(content.dom).toMatchInlineSnapshot(` 116 |
117 |

118 | Hover content 119 |

120 |
121 | `) 122 | }) 123 | -------------------------------------------------------------------------------- /src/text-document.ts: -------------------------------------------------------------------------------- 1 | import { language } from '@codemirror/language' 2 | import { type EditorState, type Extension, Facet, StateField } from '@codemirror/state' 3 | import { TextDocument } from 'vscode-languageserver-textdocument' 4 | 5 | let inmemoryDocumentCounter = 0 6 | 7 | /** 8 | * Get the first value of an array of strings. 9 | * 10 | * @param values 11 | * The input array. 12 | * @returns 13 | * The first value of the input array. 14 | */ 15 | function combine(values: readonly string[]): string { 16 | return values.at(-1)! 17 | } 18 | 19 | /** 20 | * A CodeMirror {@link Facet} used to track the text document URI. 21 | */ 22 | const uriFacet = Facet.define({ 23 | combine 24 | }) 25 | 26 | /** 27 | * A CodeMirror {@link StateField} used to track the {@link TextDocument}. 28 | */ 29 | const textDocumentField = StateField.define({ 30 | create(state) { 31 | const stateUri = state.facet(uriFacet) 32 | const stateLanguage = state.facet(language) 33 | const languageId = stateLanguage?.name || 'plaintext' 34 | 35 | return TextDocument.create(stateUri, languageId, 0, String(state.doc)) 36 | }, 37 | 38 | update(value, transaction) { 39 | const stateUri = transaction.state.facet(uriFacet) 40 | const stateLanguage = transaction.state.facet(language) 41 | const languageId = stateLanguage?.name || 'plaintext' 42 | 43 | if (stateUri !== value.uri) { 44 | return TextDocument.create(stateUri, languageId, 0, String(transaction.newDoc)) 45 | } 46 | 47 | if (transaction.docChanged || languageId !== value.languageId) { 48 | return TextDocument.create( 49 | stateUri, 50 | languageId, 51 | value.version + 1, 52 | String(transaction.newDoc) 53 | ) 54 | } 55 | 56 | return value 57 | } 58 | }) 59 | 60 | /** 61 | * Assign a {@link TextDocument} to an editor state. 62 | * 63 | * This text document is used by other extensions provided by `codemirror-languageservice`. 64 | * 65 | * The language ID is determined from the name of the 66 | * [language](https://codemirror.net/#languages) used. If this isn’t found, the language ID defaults 67 | * to `plaintext`. 68 | * 69 | * @param uri 70 | * The URI to use for the text document. If this is left unspecified, an auto-incremented 71 | * `inmemory://` URI is used. 72 | * @returns 73 | * A CodeMirror {@link Extension}. 74 | * @example 75 | * ```ts 76 | * import { json } from '@codemirror/lang-json' 77 | * import { EditorState } from '@codemirror/state' 78 | * import { textDocument } from 'codemirror-languageservice' 79 | * 80 | * const state = EditorState.create({ 81 | * doc: 'console.log("Hello world!")\n', 82 | * extensions: [ 83 | * json(), 84 | * textDocument('file:///example.js') 85 | * ] 86 | * }) 87 | * ``` 88 | */ 89 | export function textDocument(uri?: string): Extension { 90 | let realUri = uri 91 | if (!realUri) { 92 | inmemoryDocumentCounter += 1 93 | realUri = `inmemory://${inmemoryDocumentCounter}` 94 | } 95 | return [uriFacet.of(realUri), textDocumentField] 96 | } 97 | 98 | /** 99 | * Get the {@link TextDocument} for a CodeMirror {@link EditorState}. 100 | * 101 | * @param state 102 | * The editor state to get the text document for. 103 | * @returns 104 | * The text document. 105 | */ 106 | export function getTextDocument(state: EditorState): TextDocument { 107 | return state.field(textDocumentField) 108 | } 109 | -------------------------------------------------------------------------------- /src/lint.ts: -------------------------------------------------------------------------------- 1 | import { type Diagnostic as CodeMirrorDiagnostic, type LintSource } from '@codemirror/lint' 2 | import { type Diagnostic, type DiagnosticTag } from 'vscode-languageserver-protocol' 3 | import { type TextDocument } from 'vscode-languageserver-textdocument' 4 | 5 | import { getTextDocument } from './text-document.js' 6 | import { type LSPResult } from './types.js' 7 | 8 | const defaultFormatSource: NonNullable = (diagnostic) => { 9 | let result = diagnostic.source ?? '' 10 | if (diagnostic.code) { 11 | if (result) { 12 | result += ':' 13 | } 14 | return result + diagnostic.code 15 | } 16 | if (result) { 17 | return result 18 | } 19 | } 20 | 21 | export declare namespace createLintSource { 22 | interface Options { 23 | /** 24 | * Provide LSP diagnostics. 25 | * 26 | * @param textDocument 27 | * The text document for which to provide diagnostics. 28 | * @returns 29 | * An array of LSP diagnostics. 30 | */ 31 | doDiagnostics: (textDocument: TextDocument) => LSPResult> 32 | 33 | /** 34 | * Format the source of a diagnostic. 35 | * 36 | * @param diagnostic 37 | * The diagnostic for which to format the source. 38 | * @returns 39 | * The formatted source 40 | */ 41 | formatSource?: (diagnostic: Diagnostic) => string | undefined 42 | 43 | /** 44 | * An additional class for all diagnostics provided by this validation. 45 | */ 46 | markClass?: string 47 | } 48 | } 49 | 50 | /** 51 | * Create an LSP based lint source. 52 | * 53 | * By default CodeMirror provides styling for the `cm-lintRange-hint`, `cm-lintRange-info`, 54 | * `cm-lintRange-warning`, and `cm-lintRange-error` classes. This extension also uses the 55 | * `cm-lintRange-deprecated` and `cm-lintRange-unnecessary` classes which you may want to style. For 56 | * example: 57 | * 58 | * ```css 59 | *.cm-lintRange-deprecated { 60 | * background-image: none !important; 61 | * text-decoration: line-through; 62 | * } 63 | * 64 | * .cm-lintRange-unnecessary { 65 | * background-repeat: no-repeat !important; 66 | * opacity: 0.4; 67 | * } 68 | * ``` 69 | * 70 | * @param options 71 | * Options to configure the linting. 72 | * @returns 73 | * A CodeMirror lint source that uses LSP based diagnostics. 74 | */ 75 | export function createLintSource(options: createLintSource.Options): LintSource { 76 | const formatSource = options.formatSource ?? defaultFormatSource 77 | 78 | return async (view) => { 79 | const textDocument = getTextDocument(view.state) 80 | 81 | const diagnostics = await options.doDiagnostics(textDocument) 82 | const results: CodeMirrorDiagnostic[] = [] 83 | 84 | if (!diagnostics) { 85 | return results 86 | } 87 | 88 | if (textDocument.version !== getTextDocument(view.state).version) { 89 | return results 90 | } 91 | 92 | for (const diagnostic of diagnostics) { 93 | const { codeDescription, message, range, severity, tags } = diagnostic 94 | let markClass = options.markClass ?? '' 95 | if (tags?.includes(1 satisfies typeof DiagnosticTag.Unnecessary)) { 96 | markClass += ' cm-lintRange-unnecessary' 97 | } 98 | 99 | if (tags?.includes(2 satisfies typeof DiagnosticTag.Deprecated)) { 100 | markClass += ' cm-lintRange-deprecated' 101 | } 102 | 103 | results.push({ 104 | message, 105 | from: textDocument.offsetAt(range.start), 106 | to: textDocument.offsetAt(range.end), 107 | markClass, 108 | source: formatSource(diagnostic), 109 | renderMessage: codeDescription 110 | ? () => { 111 | const fragment = document.createDocumentFragment() 112 | const anchor = document.createElement('a') 113 | anchor.href = codeDescription.href 114 | anchor.textContent = codeDescription.href 115 | fragment.append(message, document.createElement('br'), anchor) 116 | return fragment 117 | } 118 | : undefined, 119 | severity: 120 | severity === 4 ? 'hint' : severity === 3 ? 'info' : severity === 2 ? 'warning' : 'error' 121 | }) 122 | } 123 | 124 | return results 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/markup-content.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | import { markdownToDom } from './utils.js' 4 | import { fromMarkupContent } from '../src/markup-content.js' 5 | 6 | test('string', () => { 7 | const fragment = fromMarkupContent( 8 | '[markdown](https://commonmark.org)', 9 | document.createDocumentFragment(), 10 | { markdownToDom } 11 | ) 12 | 13 | expect(fragment).toMatchInlineSnapshot(` 14 | 15 |

16 | 19 | markdown 20 | 21 |

22 |
23 | `) 24 | }) 25 | 26 | test('empty string', () => { 27 | const fragment = fromMarkupContent('', document.createDocumentFragment(), { markdownToDom }) 28 | 29 | expect(fragment).toMatchInlineSnapshot(` 30 | 31 | `) 32 | }) 33 | 34 | test('MarkedString', () => { 35 | const fragment = fromMarkupContent( 36 | { language: 'javascript', value: 'console.log("Hello!")\n' }, 37 | document.createDocumentFragment(), 38 | { markdownToDom } 39 | ) 40 | 41 | expect(fragment).toMatchInlineSnapshot(` 42 | 43 |
 44 |         
 47 |           console.log("Hello!")
 48 | 
 49 |         
 50 |       
51 |
52 | `) 53 | }) 54 | 55 | test('array', () => { 56 | const fragment = fromMarkupContent( 57 | [ 58 | '[markdown](https://commonmark.org)', 59 | { language: 'javascript', value: 'console.log("Hello!")\n' } 60 | ], 61 | document.createDocumentFragment(), 62 | { markdownToDom } 63 | ) 64 | 65 | expect(fragment).toMatchInlineSnapshot(` 66 | 67 |

68 | 71 | markdown 72 | 73 |

74 |
 75 |         
 78 |           console.log("Hello!")
 79 | 
 80 |         
 81 |       
82 |
83 | `) 84 | }) 85 | 86 | test('MarkupContent markdown', () => { 87 | const fragment = fromMarkupContent( 88 | { kind: 'markdown', value: '[markdown](https://commonmark.org)' }, 89 | document.createDocumentFragment(), 90 | { markdownToDom } 91 | ) 92 | 93 | expect(fragment).toMatchInlineSnapshot(` 94 | 95 |

96 | 99 | markdown 100 | 101 |

102 |
103 | `) 104 | }) 105 | 106 | test('MarkupContent plaintext', () => { 107 | const fragment = fromMarkupContent( 108 | { kind: 'plaintext', value: '[markdown](https://commonmark.org)' }, 109 | document.createDocumentFragment(), 110 | { markdownToDom } 111 | ) 112 | 113 | expect(fragment).toMatchInlineSnapshot(` 114 | 115 |

116 | [markdown](https://commonmark.org) 117 |

118 |
119 | `) 120 | }) 121 | 122 | test('markdownToDom iterable', () => { 123 | const fragment = fromMarkupContent( 124 | { kind: 'markdown', value: '[markdown](https://commonmark.org)' }, 125 | document.createDocumentFragment(), 126 | { 127 | *markdownToDom(markdown) { 128 | yield markdownToDom(markdown) 129 | } 130 | } 131 | ) 132 | 133 | expect(fragment).toMatchInlineSnapshot(` 134 | 135 |

136 | 139 | markdown 140 | 141 |

142 |
143 | `) 144 | }) 145 | 146 | test('markdownToDom null', () => { 147 | const fragment = fromMarkupContent( 148 | { kind: 'markdown', value: '[markdown](https://commonmark.org)' }, 149 | document.createDocumentFragment(), 150 | { 151 | markdownToDom() { 152 | return null 153 | } 154 | } 155 | ) 156 | 157 | expect(fragment).toMatchInlineSnapshot('') 158 | }) 159 | 160 | test('markdownToDom undefined', () => { 161 | const fragment = fromMarkupContent( 162 | { kind: 'markdown', value: '[markdown](https://commonmark.org)' }, 163 | document.createDocumentFragment(), 164 | { 165 | markdownToDom() { 166 | // Do nothing 167 | } 168 | } 169 | ) 170 | 171 | expect(fragment).toMatchInlineSnapshot('') 172 | }) 173 | -------------------------------------------------------------------------------- /example/scripts/json.ts: -------------------------------------------------------------------------------- 1 | import { autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete' 2 | import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' 3 | import { json } from '@codemirror/lang-json' 4 | import { foldGutter, foldKeymap } from '@codemirror/language' 5 | import { linter, lintKeymap } from '@codemirror/lint' 6 | import { oneDark } from '@codemirror/theme-one-dark' 7 | import { EditorView, hoverTooltip, keymap, lineNumbers } from '@codemirror/view' 8 | import { 9 | createCompletionSource, 10 | createHoverTooltipSource, 11 | createLintSource, 12 | dispatchTextEdits, 13 | getTextDocument, 14 | textDocument 15 | } from 'codemirror-languageservice' 16 | import { toDom } from 'hast-util-to-dom' 17 | import { fromMarkdown } from 'mdast-util-from-markdown' 18 | import { toHast } from 'mdast-util-to-hast' 19 | import { 20 | getLanguageService, 21 | type JSONDocument, 22 | type TextDocument 23 | } from 'vscode-json-languageservice' 24 | 25 | import pkg from '../../package.json' 26 | 27 | /** 28 | * Convert markdown content to a DOM node. 29 | * 30 | * @param markdown 31 | * The markdown content. 32 | * @returns 33 | * The DOM node that represents the markdown. 34 | */ 35 | function markdownToDom(markdown: string): Node { 36 | const mdast = fromMarkdown(markdown) 37 | const hast = toHast(mdast) 38 | const html = toDom(hast, { fragment: true }) 39 | return html 40 | } 41 | 42 | const ls = getLanguageService({ 43 | async schemaRequestService(url) { 44 | const response = await fetch(url) 45 | 46 | if (response.ok) { 47 | return response.text() 48 | } 49 | 50 | throw new Error(await response.text(), { cause: response }) 51 | } 52 | }) 53 | 54 | const jsonDocuments = new WeakMap() 55 | 56 | /** 57 | * Get a cached JSON document from a text document. 58 | * 59 | * @param document 60 | * The text document to get the matching JSON document for. 61 | * @returns 62 | * The JSON document matching the text document. 63 | */ 64 | function getJSONDocument(document: TextDocument): JSONDocument { 65 | let jsonDocument = jsonDocuments.get(document) 66 | if (!jsonDocument) { 67 | jsonDocument = ls.parseJSONDocument(document) 68 | jsonDocuments.set(document, jsonDocument) 69 | } 70 | return jsonDocument 71 | } 72 | 73 | const completionOptions: createCompletionSource.Options = { 74 | section: 'Word completion', 75 | markdownToDom, 76 | triggerCharacters: '":', 77 | // @ts-expect-error https://github.com/microsoft/vscode-json-languageservice/pull/239 78 | doComplete(document, position) { 79 | return ls.doComplete(document, position, getJSONDocument(document)) 80 | } 81 | } 82 | 83 | const hoverTooltipOptions: createHoverTooltipSource.Options = { 84 | markdownToDom, 85 | // @ts-expect-error https://github.com/microsoft/vscode-json-languageservice/pull/239 86 | doHover(document, position) { 87 | return ls.doHover(document, position, getJSONDocument(document)) 88 | } 89 | } 90 | 91 | const lintOptions: createLintSource.Options = { 92 | // @ts-expect-error https://github.com/microsoft/vscode-json-languageservice/pull/239 93 | doDiagnostics(document) { 94 | return ls.doValidation(document, getJSONDocument(document)) 95 | } 96 | } 97 | 98 | const doc = JSON.stringify( 99 | { 100 | $schema: 'https://json.schemastore.org/package.json', 101 | ...pkg 102 | }, 103 | undefined, 104 | 2 105 | ) 106 | 107 | const view = new EditorView({ 108 | doc, 109 | parent: document.body, 110 | extensions: [ 111 | textDocument('file:///example.json'), 112 | json(), 113 | lineNumbers(), 114 | oneDark, 115 | foldGutter(), 116 | history(), 117 | autocompletion({ 118 | override: [createCompletionSource(completionOptions)] 119 | }), 120 | json(), 121 | keymap.of([ 122 | ...closeBracketsKeymap, 123 | ...defaultKeymap, 124 | ...historyKeymap, 125 | ...foldKeymap, 126 | ...completionKeymap, 127 | ...lintKeymap 128 | ]), 129 | hoverTooltip(createHoverTooltipSource(hoverTooltipOptions)), 130 | linter(createLintSource(lintOptions)) 131 | ] 132 | }) 133 | 134 | document.getElementById('format-button')!.addEventListener('click', () => { 135 | const document = getTextDocument(view.state) 136 | const text = document.getText() 137 | const start = document.positionAt(0) 138 | const end = document.positionAt(text.length) 139 | const edits = ls.format(document, { start, end }, { insertSpaces: true, tabSize: 2 }) 140 | 141 | dispatchTextEdits(view, edits) 142 | }) 143 | -------------------------------------------------------------------------------- /example/scripts/index.ts: -------------------------------------------------------------------------------- 1 | import { autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete' 2 | import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' 3 | import { json } from '@codemirror/lang-json' 4 | import { foldGutter, foldKeymap } from '@codemirror/language' 5 | import { linter, lintKeymap } from '@codemirror/lint' 6 | import { oneDark } from '@codemirror/theme-one-dark' 7 | import { EditorView, hoverTooltip, keymap, lineNumbers } from '@codemirror/view' 8 | import { 9 | createCompletionSource, 10 | createHoverTooltipSource, 11 | createLintSource, 12 | dispatchTextEdits, 13 | getTextDocument, 14 | textDocument 15 | } from 'codemirror-languageservice' 16 | import { toDom } from 'hast-util-to-dom' 17 | import { fromMarkdown } from 'mdast-util-from-markdown' 18 | import { toHast } from 'mdast-util-to-hast' 19 | import { 20 | CompletionItemKind, 21 | DiagnosticSeverity, 22 | DiagnosticTag, 23 | InsertTextFormat 24 | } from 'vscode-languageserver-types' 25 | 26 | /** 27 | * Convert markdown content to a DOM node. 28 | * 29 | * @param markdown 30 | * The markdown content. 31 | * @returns 32 | * The DOM node that represents the markdown. 33 | */ 34 | function markdownToDom(markdown: string): Node { 35 | const mdast = fromMarkdown(markdown) 36 | const hast = toHast(mdast) 37 | return toDom(hast, { fragment: true }) 38 | } 39 | 40 | const completionOptions: createCompletionSource.Options = { 41 | section: 'Word completion', 42 | markdownToDom, 43 | *doComplete(document, position, context) { 44 | const text = document.getText() 45 | const start = document.positionAt(document.offsetAt(position) - 1) 46 | 47 | if (!context.triggerCharacter) { 48 | return 49 | } 50 | 51 | for (const match of text.matchAll(/\b\S+\b/g)) { 52 | const [word] = match 53 | 54 | if (!word.startsWith(context.triggerCharacter)) { 55 | continue 56 | } 57 | 58 | yield { 59 | label: word, 60 | kind: CompletionItemKind.Text, 61 | insertTextFormat: InsertTextFormat.Snippet, 62 | detail: 'text', 63 | documentation: `Insert the text _“${word}”_ here`, 64 | textEdit: { 65 | newText: word, 66 | range: { 67 | start, 68 | end: position 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | const hoverTooltipOptions: createHoverTooltipSource.Options = { 77 | markdownToDom, 78 | doHover(document, position) { 79 | const text = document.getText() 80 | const offset = document.offsetAt(position) 81 | 82 | for (const match of text.matchAll(/\b\S+\b/g)) { 83 | const [word] = match 84 | const start = match.index 85 | const end = start + word.length 86 | 87 | if (offset >= start && offset <= end) { 88 | return { 89 | contents: `You are hovering the word _“${word}”_`, 90 | range: { 91 | start: document.positionAt(start), 92 | end: document.positionAt(end) 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | const lintOptions: createLintSource.Options = { 101 | *doDiagnostics(document) { 102 | const text = document.getText() 103 | 104 | for (const match of text.matchAll(/deprecated|hint|info|warning|error|unnecessary/gi)) { 105 | const [word] = match 106 | const start = match.index 107 | const end = start + word.length 108 | const tags: DiagnosticTag[] = [] 109 | const lower = word.toLowerCase() 110 | 111 | if (lower === 'deprecated') { 112 | tags.push(DiagnosticTag.Deprecated) 113 | } else if (lower === 'unnecessary') { 114 | tags.push(DiagnosticTag.Unnecessary) 115 | } 116 | 117 | yield { 118 | message: `Invalid word “${word}”`, 119 | severity: 120 | lower === 'error' 121 | ? DiagnosticSeverity.Error 122 | : lower === 'warning' 123 | ? DiagnosticSeverity.Warning 124 | : lower === 'info' 125 | ? DiagnosticSeverity.Information 126 | : DiagnosticSeverity.Hint, 127 | source: 'regexp', 128 | code: lower, 129 | codeDescription: { 130 | href: 'https://example.com' 131 | }, 132 | tags, 133 | range: { 134 | start: document.positionAt(start), 135 | end: document.positionAt(end) 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | const doc = `This demo shows how you can integrate an LSP based language service 143 | into CodeMirror. 144 | 145 | The completion source autocompletes words based on the words in the document and 146 | the character typed. 147 | 148 | The hovert tooltip source shows a tooltip which displays the word you’re 149 | hovering over. 150 | 151 | The lint source shows diagnostics for the words hint, info, warning, error, 152 | unnecessary, and deprecated. 153 | 154 | CodeMirror doesn’t have a builtin formatter, but you can apply your own 155 | formatting solution. The “Lowercase” button applies LSP compatible text edits to 156 | make all text lowercase, whereas the “Uppercase” button applies LSP compatible 157 | text edits to make all text uppercase. 158 | ` 159 | 160 | const view = new EditorView({ 161 | doc, 162 | parent: document.body, 163 | extensions: [ 164 | textDocument('file:///example.txt'), 165 | lineNumbers(), 166 | oneDark, 167 | foldGutter(), 168 | history(), 169 | autocompletion({ 170 | override: [createCompletionSource(completionOptions)] 171 | }), 172 | json(), 173 | keymap.of([ 174 | ...closeBracketsKeymap, 175 | ...defaultKeymap, 176 | ...historyKeymap, 177 | ...foldKeymap, 178 | ...completionKeymap, 179 | ...lintKeymap 180 | ]), 181 | hoverTooltip(createHoverTooltipSource(hoverTooltipOptions)), 182 | linter(createLintSource(lintOptions)) 183 | ] 184 | }) 185 | 186 | document.getElementById('lowercase-button')!.addEventListener('click', () => { 187 | const document = getTextDocument(view.state) 188 | const text = document.getText() 189 | 190 | dispatchTextEdits(view, [ 191 | { 192 | newText: text.toLowerCase(), 193 | range: { 194 | start: document.positionAt(0), 195 | end: document.positionAt(text.length) 196 | } 197 | } 198 | ]) 199 | }) 200 | 201 | document.getElementById('uppercase-button')!.addEventListener('click', () => { 202 | const document = getTextDocument(view.state) 203 | const text = document.getText() 204 | 205 | dispatchTextEdits(view, [ 206 | { 207 | newText: text.toUpperCase(), 208 | range: { 209 | start: document.positionAt(0), 210 | end: document.positionAt(text.length) 211 | } 212 | } 213 | ]) 214 | }) 215 | -------------------------------------------------------------------------------- /src/completion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Completion, 3 | type CompletionSource, 4 | insertCompletionText, 5 | snippet 6 | } from '@codemirror/autocomplete' 7 | import { 8 | type CompletionContext, 9 | type CompletionItem, 10 | type CompletionItemKind, 11 | type CompletionList, 12 | type CompletionTriggerKind, 13 | type InsertTextFormat, 14 | type Position 15 | } from 'vscode-languageserver-protocol' 16 | import { type TextDocument } from 'vscode-languageserver-textdocument' 17 | 18 | import { fromMarkupContent } from './markup-content.js' 19 | import { getTextDocument } from './text-document.js' 20 | import { type LSPResult } from './types.js' 21 | 22 | let alphabet = 'abcdefghijklmnopqrstuvwxyz' 23 | alphabet += alphabet.toUpperCase() 24 | 25 | const defaultFromCompletionItemKind: NonNullable< 26 | createCompletionSource.Options['fromCompletionItemKind'] 27 | > = (kind) => { 28 | switch (kind) { 29 | case 1 satisfies typeof CompletionItemKind.Text: 30 | case 15 satisfies typeof CompletionItemKind.Snippet: 31 | return 'text' 32 | 33 | case 2 satisfies typeof CompletionItemKind.Method: 34 | return 'method' 35 | 36 | case 3 satisfies typeof CompletionItemKind.Function: 37 | return 'function' 38 | 39 | case 4 satisfies typeof CompletionItemKind.Constructor: 40 | case 7 satisfies typeof CompletionItemKind.Class: 41 | return 'class' 42 | 43 | case 5 satisfies typeof CompletionItemKind.Field: 44 | case 10 satisfies typeof CompletionItemKind.Property: 45 | return 'property' 46 | 47 | case 6 satisfies typeof CompletionItemKind.Variable: 48 | case 12 satisfies typeof CompletionItemKind.Value: 49 | case 18 satisfies typeof CompletionItemKind.Reference: 50 | case 23 satisfies typeof CompletionItemKind.Event: 51 | return 'variable' 52 | 53 | case 8 satisfies typeof CompletionItemKind.Interface: 54 | case 22 satisfies typeof CompletionItemKind.Struct: 55 | case 25 satisfies typeof CompletionItemKind.TypeParameter: 56 | return 'type' 57 | 58 | case 9 satisfies typeof CompletionItemKind.Module: 59 | return 'namespace' 60 | 61 | case 13 satisfies typeof CompletionItemKind.Enum: 62 | return 'enum' 63 | 64 | case 11 satisfies typeof CompletionItemKind.Unit: 65 | case 14 satisfies typeof CompletionItemKind.Keyword: 66 | case 24 satisfies typeof CompletionItemKind.Operator: 67 | return 'keyword' 68 | 69 | case 16 satisfies typeof CompletionItemKind.Color: 70 | case 21 satisfies typeof CompletionItemKind.Constant: 71 | return 'constant' 72 | 73 | case 20 satisfies typeof CompletionItemKind.EnumMember: 74 | return 'enum' 75 | 76 | default: 77 | } 78 | } 79 | 80 | export declare namespace createCompletionSource { 81 | interface Options extends fromMarkupContent.Options { 82 | /** 83 | * Convert an LSP completion item kind to a CodeMirror completion type. 84 | * 85 | * @param kind 86 | * The LSP completion item kind to convert 87 | * @returns 88 | * The CodeMirror completion type. 89 | */ 90 | fromCompletionItemKind?: (kind: CompletionItemKind | undefined) => string | undefined 91 | 92 | /** 93 | * Provide LSP completions items. 94 | * 95 | * @param textDocument 96 | * The text document for which to provide completion items. 97 | * @param position 98 | * The position for which to provide completion items. 99 | * @param context 100 | * The completion context. 101 | * @returns 102 | * A completion list, or just the items as an iterable. 103 | */ 104 | doComplete: ( 105 | textDocument: TextDocument, 106 | position: Position, 107 | context: CompletionContext 108 | ) => LSPResult> 109 | 110 | /** 111 | * The section to use for completions. 112 | */ 113 | section?: string 114 | 115 | /** 116 | * Only trigger completions automatically when one of these characters is typed. 117 | */ 118 | triggerCharacters?: string 119 | } 120 | } 121 | 122 | /** 123 | * Create an LSP based completion source. 124 | * 125 | * @param options 126 | * Options to configure the completion. 127 | * @returns 128 | * A CodeMirror completion source that uses LSP based completions. 129 | */ 130 | export function createCompletionSource(options: createCompletionSource.Options): CompletionSource { 131 | const fromCompletionItemKind = options.fromCompletionItemKind ?? defaultFromCompletionItemKind 132 | let triggerCharacters = alphabet 133 | if (options.triggerCharacters) { 134 | triggerCharacters += options.triggerCharacters 135 | } 136 | 137 | return async (context) => { 138 | const textDocument = getTextDocument(context.state) 139 | 140 | let completionContext: CompletionContext 141 | if (context.explicit) { 142 | completionContext = { 143 | triggerKind: 1 satisfies typeof CompletionTriggerKind.Invoked 144 | } 145 | } else { 146 | const triggerCharacter = context.state.sliceDoc(context.pos - 1, context.pos) 147 | if (!triggerCharacters.includes(triggerCharacter)) { 148 | return null 149 | } 150 | 151 | completionContext = { 152 | triggerCharacter, 153 | triggerKind: 2 satisfies typeof CompletionTriggerKind.TriggerCharacter 154 | } 155 | } 156 | 157 | const completions = await options.doComplete( 158 | textDocument, 159 | textDocument.positionAt(context.pos), 160 | completionContext 161 | ) 162 | 163 | if (!completions) { 164 | return null 165 | } 166 | 167 | if (textDocument.version !== getTextDocument(context.view?.state ?? context.state).version) { 168 | return null 169 | } 170 | 171 | let items: Iterable 172 | let itemDefaults: CompletionList['itemDefaults'] 173 | if (Symbol.iterator in completions) { 174 | items = completions 175 | } else { 176 | items = completions.items 177 | itemDefaults = completions.itemDefaults 178 | } 179 | 180 | const completionOptions: Completion[] = [] 181 | let minFrom = context.pos 182 | let maxTo = context.pos 183 | 184 | for (const item of items) { 185 | const { commitCharacters, detail, documentation, kind, label, textEdit, textEditText } = item 186 | const completion: Completion = { 187 | commitCharacters, 188 | detail, 189 | info: 190 | documentation && 191 | (() => fromMarkupContent(documentation, document.createDocumentFragment(), options)), 192 | label, 193 | section: options.section, 194 | type: fromCompletionItemKind(kind) 195 | } 196 | 197 | if (textEdit) { 198 | const range = 'range' in textEdit ? textEdit.range : textEdit.replace 199 | const from = textDocument.offsetAt(range.start) 200 | const to = textDocument.offsetAt(range.end) 201 | if (from < minFrom) { 202 | minFrom = from 203 | } 204 | if (to > maxTo) { 205 | maxTo = to 206 | } 207 | const insert = textEdit.newText 208 | const insertTextFormat = item.insertTextFormat ?? itemDefaults?.insertTextFormat 209 | 210 | completion.apply = (view) => 211 | insertTextFormat === (2 satisfies typeof InsertTextFormat.Snippet) 212 | ? snippet(insert.replaceAll(/\$(\d+)/g, '$${$1}'))(view, completion, from, to) 213 | : view.dispatch(insertCompletionText(view.state, insert, from, to)) 214 | } else if (textEditText) { 215 | completion.apply = textEditText 216 | } 217 | 218 | completionOptions.push(completion) 219 | } 220 | 221 | return { 222 | from: minFrom, 223 | to: maxTo, 224 | commitCharacters: itemDefaults?.commitCharacters, 225 | options: completionOptions 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /example/scripts/typescript.ts: -------------------------------------------------------------------------------- 1 | import { autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete' 2 | import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' 3 | import { javascript } from '@codemirror/lang-javascript' 4 | import { foldGutter, foldKeymap } from '@codemirror/language' 5 | import { linter, lintKeymap } from '@codemirror/lint' 6 | import { oneDark } from '@codemirror/theme-one-dark' 7 | import { EditorView, hoverTooltip, keymap, lineNumbers } from '@codemirror/view' 8 | import { createNpmFileSystem } from '@volar/jsdelivr' 9 | import { 10 | createLanguage, 11 | createLanguageService, 12 | createUriMap, 13 | type LanguageServiceEnvironment, 14 | type ProjectContext, 15 | type SourceScript 16 | } from '@volar/language-service' 17 | import { createLanguageServiceHost, createSys, resolveFileLanguageId } from '@volar/typescript' 18 | import { 19 | createCompletionSource, 20 | createHoverTooltipSource, 21 | createLintSource, 22 | dispatchTextEdits, 23 | getTextDocument, 24 | textDocument 25 | } from 'codemirror-languageservice' 26 | import { toDom } from 'hast-util-to-dom' 27 | import { fromMarkdown } from 'mdast-util-from-markdown' 28 | import { toHast } from 'mdast-util-to-hast' 29 | import * as ts from 'typescript' 30 | import { create as createTypeScriptPlugins } from 'volar-service-typescript' 31 | import { type TextDocument } from 'vscode-languageserver-textdocument' 32 | import { URI } from 'vscode-uri' 33 | 34 | globalThis.process = { 35 | cwd() { 36 | return '/' 37 | } 38 | } as NodeJS.Process 39 | 40 | /** 41 | * Convert markdown content to a DOM node. 42 | * 43 | * @param markdown 44 | * The markdown content. 45 | * @returns 46 | * The DOM node that represents the markdown. 47 | */ 48 | function markdownToDom(markdown: string): Node { 49 | const mdast = fromMarkdown(markdown) 50 | const hast = toHast(mdast) 51 | const html = toDom(hast, { fragment: true }) 52 | return html 53 | } 54 | 55 | const docUri = 'file:///example.tsx' 56 | const docText = `import { ChangeEventHandler, ReactNode, useState } from 'react' 57 | 58 | export namespace Greeting { 59 | export interface Props { 60 | /** 61 | * The name of the person to greet. 62 | */ 63 | name: string 64 | } 65 | } 66 | 67 | /** 68 | * Render a greeting for a person. 69 | */ 70 | export function Greeting({ name }: Greeting.Props): ReactNode { 71 | console.log('Hello', \`$\{name}!\`) 72 | 73 | return ( 74 |
75 | Hello {name}! 76 |
77 | ) 78 | } 79 | 80 | /** 81 | * Render the app. 82 | */ 83 | export function App(): ReactNode { 84 | const [name, setName] = useState('Volar') 85 | 86 | const handleChange: ChangeEventHandler = (event) => { 87 | setName(event.currentTarget.name) 88 | } 89 | 90 | return ( 91 |
92 | 93 | 94 |
95 | ) 96 | } 97 | ` 98 | 99 | const env: LanguageServiceEnvironment = { 100 | fs: createNpmFileSystem(), 101 | workspaceFolders: [] 102 | } 103 | const uriConverter = { 104 | asUri: URI.file, 105 | asFileName(uri: URI) { 106 | return uri.path 107 | } 108 | } 109 | const sys = createSys(ts.sys, env, () => '', uriConverter) 110 | const syncDocuments = 111 | createUriMap<[TextDocument, number | undefined, ts.IScriptSnapshot | undefined]>() 112 | const fsFileSnapshots = createUriMap<[number | undefined, ts.IScriptSnapshot | undefined]>() 113 | const language = createLanguage( 114 | [ 115 | { 116 | getLanguageId: (uri) => syncDocuments.get(uri)?.[0].languageId 117 | }, 118 | { 119 | getLanguageId: (uri) => resolveFileLanguageId(uri.path) 120 | } 121 | ], 122 | createUriMap>(false), 123 | (uri, includeFsFiles) => { 124 | let snapshot: ts.IScriptSnapshot | undefined 125 | 126 | const syncDocument = syncDocuments.get(uri) 127 | if (syncDocument) { 128 | if (!syncDocument[2] || syncDocument[0].version !== syncDocument[1]) { 129 | syncDocument[1] = syncDocument[0].version 130 | syncDocument[2] = ts.ScriptSnapshot.fromString(syncDocument[0].getText()) 131 | } 132 | snapshot = syncDocument[2] 133 | } else if (includeFsFiles) { 134 | const cache = fsFileSnapshots.get(uri) 135 | const fileName = uriConverter.asFileName(uri) 136 | const modifiedTime = sys.getModifiedTime?.(fileName)?.valueOf() 137 | if (!cache || cache[0] !== modifiedTime) { 138 | if (sys.fileExists(fileName)) { 139 | const text = sys.readFile(fileName) 140 | fsFileSnapshots.set(uri, [ 141 | modifiedTime, 142 | text === undefined ? undefined : ts.ScriptSnapshot.fromString(text) 143 | ]) 144 | } else { 145 | fsFileSnapshots.set(uri, [modifiedTime, undefined]) 146 | } 147 | } 148 | snapshot = fsFileSnapshots.get(uri)?.[1] 149 | } 150 | 151 | if (snapshot) { 152 | language.scripts.set(uri, snapshot) 153 | } else { 154 | language.scripts.delete(uri) 155 | } 156 | } 157 | ) 158 | const project: ProjectContext = { 159 | typescript: { 160 | configFileName: '', 161 | sys, 162 | uriConverter, 163 | ...createLanguageServiceHost(ts, sys, language, URI.file, { 164 | getCompilationSettings() { 165 | return { 166 | checkJs: true, 167 | jsx: ts.JsxEmit.ReactJSX, 168 | module: ts.ModuleKind.Preserve, 169 | target: ts.ScriptTarget.ESNext 170 | } 171 | }, 172 | getCurrentDirectory() { 173 | return sys.getCurrentDirectory() 174 | }, 175 | getScriptFileNames() { 176 | return [docUri.slice('file://'.length)] 177 | } 178 | }) 179 | } 180 | } 181 | const languageService = createLanguageService( 182 | language, 183 | createTypeScriptPlugins(ts, {}), 184 | env, 185 | project 186 | ) 187 | 188 | /** 189 | * Synchronize a document from CodeMirror into Volar. 190 | * 191 | * @param document 192 | * The document to synchronize. 193 | * @returns 194 | * The URI that matches the document. 195 | */ 196 | function sync(document: TextDocument): URI { 197 | const uri = URI.parse(document.uri) 198 | if (syncDocuments.has(uri)) { 199 | syncDocuments.get(uri)![0] = document 200 | } else { 201 | syncDocuments.set(uri, [document, undefined, undefined]) 202 | } 203 | return uri 204 | } 205 | 206 | const completionOptions: createCompletionSource.Options = { 207 | section: 'TypeScript', 208 | markdownToDom, 209 | triggerCharacters: '":', 210 | doComplete(document, position) { 211 | return languageService.getCompletionItems(sync(document), position) 212 | } 213 | } 214 | 215 | const hoverTooltipOptions: createHoverTooltipSource.Options = { 216 | markdownToDom, 217 | doHover(document, position) { 218 | return languageService.getHover(sync(document), position) 219 | } 220 | } 221 | 222 | const lintOptions: createLintSource.Options = { 223 | doDiagnostics(document) { 224 | return languageService.getDiagnostics(sync(document)) 225 | } 226 | } 227 | 228 | const view = new EditorView({ 229 | doc: docText, 230 | parent: document.body, 231 | extensions: [ 232 | textDocument(docUri), 233 | javascript(), 234 | lineNumbers(), 235 | oneDark, 236 | foldGutter(), 237 | history(), 238 | autocompletion({ 239 | override: [createCompletionSource(completionOptions)] 240 | }), 241 | keymap.of([ 242 | ...closeBracketsKeymap, 243 | ...defaultKeymap, 244 | ...historyKeymap, 245 | ...foldKeymap, 246 | ...completionKeymap, 247 | ...lintKeymap 248 | ]), 249 | hoverTooltip(createHoverTooltipSource(hoverTooltipOptions)), 250 | linter(createLintSource(lintOptions)) 251 | ] 252 | }) 253 | 254 | document.getElementById('format-button')!.addEventListener('click', async () => { 255 | const document = getTextDocument(view.state) 256 | const text = document.getText() 257 | const start = document.positionAt(0) 258 | const end = document.positionAt(text.length) 259 | const edits = await languageService.getDocumentFormattingEdits( 260 | URI.parse(document.uri), 261 | { insertSpaces: true, tabSize: 2 }, 262 | { start, end }, 263 | // eslint-disable-next-line unicorn/no-useless-undefined 264 | undefined 265 | ) 266 | 267 | if (edits) { 268 | dispatchTextEdits(view, edits) 269 | } 270 | }) 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codemirror-languageservice 2 | 3 | [![github actions](https://github.com/remcohaszing/codemirror-languageservice/actions/workflows/ci.yaml/badge.svg)](https://github.com/remcohaszing/codemirror-languageservice/actions/workflows/ci.yaml) 4 | [![codecov](https://codecov.io/gh/remcohaszing/codemirror-languageservice/branch/main/graph/badge.svg)](https://codecov.io/gh/remcohaszing/codemirror-languageservice) 5 | [![npm version](https://img.shields.io/npm/v/codemirror-languageservice)](https://www.npmjs.com/package/codemirror-languageservice) 6 | [![npm downloads](https://img.shields.io/npm/dm/codemirror-languageservice)](https://www.npmjs.com/package/codemirror-languageservice) 7 | 8 | Integrate a [Language Server Protocol][lsp] compatible language service into [CodeMirror][]. 9 | 10 | ## Table of Contents 11 | 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [API](#api) 15 | - [`createCompletionSource(options)`](#createcompletionsourceoptions) 16 | - [`createHoverTooltipSource(options)`](#createhovertooltipsourceoptions) 17 | - [`createLintSource(options)`](#createlintsourceoptions) 18 | - [`dispatchTextEdits(view, edits)`](#dispatchtexteditsview-edits) 19 | - [`getTextDocument(state)`](#gettextdocumentstate) 20 | - [`textDocument(uri?)`](#textdocumenturi) 21 | - [Example](#example) 22 | - [Compatibility](#compatibility) 23 | - [Contributing](#contributing) 24 | - [Related projects](#related-projects) 25 | - [License](#license) 26 | 27 | ## Installation 28 | 29 | This package has peer dependencies on the following packages: 30 | 31 | - [`@codemirror/autocomplete`](https://www.npmjs.com/package/@codemirror/autocomplete) 32 | - [`@codemirror/language`](https://www.npmjs.com/package/@codemirror/language) 33 | - [`@codemirror/lint`](https://www.npmjs.com/package/@codemirror/lint) 34 | - [`@codemirror/state`](https://www.npmjs.com/package/@codemirror/state) 35 | - [`@codemirror/view`](https://www.npmjs.com/package/@codemirror/view) 36 | 37 | Since you will probably import these directly yourself, it’s recommended to install all of them 38 | explicitly. 39 | 40 | ```sh 41 | npm install \ 42 | @codemirror/autocomplete \ 43 | @codemirror/language \ 44 | @codemirror/lint \ 45 | @codemirror/state \ 46 | @codemirror/view \ 47 | codemirror-languageservice 48 | ``` 49 | 50 | ## Usage 51 | 52 | - First, create a [CodeMirror][] `EditorView` as usual. 53 | - Since [LSP][] is based heavily on files and URIs, you need to add the `textDocument()` extension 54 | to your editor. It’s recommended to pass a file URI. If none is given, a URI is generated that 55 | uses the `inmemory://` protocol. 56 | - It’s recommended to add a language extension. This is used to detect the `languageId` for text 57 | documents. If this isn’t available, the `plaintext` language is used. 58 | - Since [LSP][] uses markdown, you need to provide a function to convert markdown to DOM. A good 59 | option is to combine [`hast-util-to-dom`](https://github.com/syntax-tree/hast-util-to-dom), 60 | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown), and 61 | [`mdast-util-to-hast`](https://github.com/syntax-tree/mdast-util-to-hast). 62 | - Add your language service integrations. 63 | 64 | ```js 65 | import { autocompletion } from '@codemirror/autocomplete' 66 | import { json } from '@codemirror/lang-json' 67 | import { linter } from '@codemirror/lint' 68 | import { EditorView, hoverTooltip } from '@codemirror/view' 69 | import { 70 | createCompletionSource, 71 | createHoverTooltipSource, 72 | createLintSource, 73 | textDocument 74 | } from 'codemirror-languageservice' 75 | import { toDom } from 'hast-util-to-dom' 76 | import { fromMarkdown } from 'mdast-util-from-markdown' 77 | import { toHast } from 'mdast-util-to-hast' 78 | 79 | import { doComplete, doDiagnostics, doHover } from './my-language-service.js' 80 | 81 | function markdownToDom(markdown) { 82 | const mdast = fromMarkdown(markdown) 83 | const hast = toHast(mdast) 84 | return toDom(hast, { fragment: true }) 85 | } 86 | 87 | const view = new EditorView({ 88 | doc: '', 89 | parent: document.getElementById('my-editor'), 90 | extensions: [ 91 | json(), 92 | textDocument('file:///example.txt'), 93 | autocompletion({ 94 | override: [createCompletionSource({ doComplete, markdownToDom })] 95 | }), 96 | hoverTooltip(createHoverTooltipSource({ doHover, markdownToDom })), 97 | linter(createLintSource({ doDiagnostics })) 98 | ] 99 | }) 100 | ``` 101 | 102 | ## API 103 | 104 | ### `createCompletionSource(options)` 105 | 106 | Create an LSP based completion source. 107 | 108 | #### Options 109 | 110 | - `doComplete` (`Function`) — Provide LSP completions items. 111 | - `markdownToDom` (`Function`) — Convert a markdown string to DOM. 112 | - `fromCompletionItemKind` (`Function`, optional) — Convert an LSP completion item kind to a 113 | CodeMirror completion type. 114 | - `section` (`string`, optional) — The section to use for completions. 115 | - `triggerCharacters` (`string`) — Only trigger completions automatically when one of these 116 | characters is typed. 117 | 118 | #### Returns 119 | 120 | A CodeMirror completion source that uses LSP based completions. 121 | ([`CompletionSource`](https://codemirror.net/docs/ref/#autocomplete.CompletionSource)) 122 | 123 | ### `createHoverTooltipSource(options)` 124 | 125 | Create an LSP based hover tooltip provider. 126 | 127 | #### Options 128 | 129 | - `doHover` (`Function`) — Provide LSP hover info 130 | - `markdownToDom` (`Function`) — Convert a markdown string to DOM. 131 | 132 | #### Returns 133 | 134 | A CodeMirror hover tooltip source that uses LSP based hover information. 135 | ([`HoverTooltipSource`](https://codemirror.net/docs/ref/#view.HoverTooltipSource)) 136 | 137 | ### `createLintSource(options)` 138 | 139 | Create an LSP based lint source. 140 | 141 | By default CodeMirror provides styling for the `cm-lintRange-hint`, `cm-lintRange-info`, 142 | `cm-lintRange-warning`, and `cm-lintRange-error` classes. This extension also uses the 143 | `cm-lintRange-deprecated` and `cm-lintRange-unnecessary` classes which you may want to style. For 144 | example: 145 | 146 | ```css 147 | .cm-lintRange-deprecated { 148 | background-image: none !important; 149 | text-decoration: line-through; 150 | } 151 | 152 | .cm-lintRange-unnecessary { 153 | background-repeat: no-repeat !important; 154 | opacity: 0.4; 155 | } 156 | ``` 157 | 158 | #### Options 159 | 160 | - `doDiagnostics` (`Function`) — Provide LSP diagnostics 161 | - `formatSource` (`Function`, optional) — Format the source of a diagnostic. 162 | - `markClass` (`string`, optional) — An additional class for all diagnostics provided by this 163 | validation. 164 | 165 | #### Returns 166 | 167 | A CodeMirror lint source that uses LSP based diagnostics. 168 | ([`LintSource`](https://codemirror.net/docs/ref/#lint.LintSource)) 169 | 170 | ### `dispatchTextEdits(view, edits)` 171 | 172 | Apply LSP text edits to an CodeMirror `EditorView`. 173 | 174 | #### Parameters 175 | 176 | - `view` ([`EditorView`](https://codemirror.net/docs/ref/#view.EditorView)) — The view to dispatch 177 | the changes to. 178 | - `edits` 179 | ([`TextEdit[]`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textEditArray)) 180 | — The edits that should be applied. 181 | 182 | ### `getTextDocument(state)` 183 | 184 | Get the text document for a CodeMirror editor state. 185 | 186 | #### Parameters 187 | 188 | - `state` ([`EditorState`](https://codemirror.net/docs/ref/#state.EditorState)) — The editor state 189 | to get the text document for. 190 | 191 | #### Returns 192 | 193 | The text document. 194 | ([`TextDocument`](https://github.com/microsoft/vscode-languageserver-node/tree/main/textDocument)) 195 | 196 | ### `textDocument(uri?)` 197 | 198 | Assign a text document to an editor state. 199 | 200 | This text document is used by other extensions provided by `codemirror-languageservice`. 201 | 202 | The language ID is determined from the name of the [language](https://codemirror.net/#languages) 203 | used. If this isn’t found, the language ID defaults to `plaintext`. 204 | 205 | #### Parameters 206 | 207 | - `uri` (`string`) —The URI to use for the text document. If this is left unspecified, an 208 | auto-incremented `inmemory://` URI is used. 209 | 210 | #### Returns 211 | 212 | A CodeMirror extension. ([`Extension`](https://codemirror.net/docs/ref/#state.Extension)) 213 | 214 | ## Example 215 | 216 | There’s an example available in the 217 | [`example`](https://github.com/remcohaszing/codemirror-languageservice/blob/main/example) directory. 218 | 219 | ## Compatibility 220 | 221 | This project is compatible with [evergreen](https://www.w3.org/2001/tag/doc/evergreen-web) browsers. 222 | 223 | ## Contributing 224 | 225 | This project provides [LSP][] based integrations for [CodeMirror][]. However, not all LSP features 226 | map well to CodeMirror. The goal is only to provide integrations that make sense for CodeMirror. If 227 | you have a pragmatic idea to integrate another LSP method, feel free to open a 228 | [new issue](https://github.com/remcohaszing/codemirror-languageservice/issues/new). 229 | 230 | On top of that, see my general 231 | [contributing guidelines](https://github.com/remcohaszing/.github/blob/main/CONTRIBUTING.md). 232 | 233 | ## Related projects 234 | 235 | - [CodeMirror][] — CodeMirror is a code editor component for the web. 236 | - [Language Server Protocol][LSP] — The Language Server Protocol (LSP) defines the protocol used 237 | between an editor or IDE and a language server that provides language features like auto complete, 238 | go to definition, find all references etc. 239 | - [Transloadit](https://transloadit.com) — A simple API to handle any file in your app. 240 | `codemirror-languageservice` was developed as part of the Transloadit JSON editor. 241 | 242 | Known language services that you could use this for: 243 | 244 | - [`vscode-css-languageservice`](https://github.com/microsoft/vscode-css-languageservice) — CSS, 245 | LESS & SCSS language service extracted from VSCode to be reused. 246 | - [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice) — 247 | Language services for HTML. 248 | - [`vscode-json-languageservice`](https://github.com/microsoft/vscode-json-languageservice) — JSON 249 | language service extracted from VSCode to be reused. 250 | - [`vscode-markdown-languageservice`](https://github.com/microsoft/vscode-markdown-languageservice) 251 | — The language service that powers VS Code’s Markdown support. 252 | - [`yaml-language-server`](https://github.com/redhat-developer/yaml-language-server) — Language 253 | Server for YAML Files. 254 | - [`@tailwindcss/language-service`](https://github.com/tailwindlabs/tailwindcss-intellisense) — 255 | About Intelligent Tailwind CSS tooling. 256 | - [`@volar/language-service`](https://volarjs.dev) — The Embedded Language Tooling Framework. 257 | 258 | ## License 259 | 260 | [MIT](LICENSE.md) © [Remco Haszing](https://github.com/remcohaszing) 261 | 262 | [codemirror]: https://codemirror.net 263 | [lsp]: https://microsoft.github.io/language-server-protocol 264 | -------------------------------------------------------------------------------- /test/lint.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view' 2 | import { createLintSource, getTextDocument, textDocument } from 'codemirror-languageservice' 3 | import { expect, test } from 'vitest' 4 | import { DiagnosticSeverity, DiagnosticTag } from 'vscode-languageserver-protocol' 5 | import { type TextDocument } from 'vscode-languageserver-textdocument' 6 | 7 | test('diagnostics args', async () => { 8 | let document: TextDocument | undefined 9 | 10 | const view = new EditorView({ 11 | doc: 'Text\n', 12 | extensions: [textDocument()] 13 | }) 14 | 15 | const lintSource = createLintSource({ 16 | doDiagnostics(doc) { 17 | document = doc 18 | } 19 | }) 20 | 21 | await lintSource(view) 22 | 23 | expect(document).toBe(getTextDocument(view.state)) 24 | }) 25 | 26 | test('ignore outdated document', async () => { 27 | const view = new EditorView({ 28 | doc: 'Text\n', 29 | extensions: [textDocument()] 30 | }) 31 | 32 | const lintSource = createLintSource({ 33 | doDiagnostics() { 34 | view.dispatch({ changes: [{ from: 0, to: 4, insert: 'Updated' }] }) 35 | 36 | return [ 37 | { 38 | message: 'This is outdated', 39 | range: { 40 | start: { line: 0, character: 0 }, 41 | end: { line: 0, character: 0 } 42 | } 43 | } 44 | ] 45 | } 46 | }) 47 | 48 | const diagnostics = await lintSource(view) 49 | 50 | expect(diagnostics).toStrictEqual([]) 51 | }) 52 | 53 | test('handle null', async () => { 54 | const view = new EditorView({ 55 | doc: 'Text\n', 56 | extensions: [textDocument()] 57 | }) 58 | 59 | const lintSource = createLintSource({ 60 | doDiagnostics() { 61 | return null 62 | } 63 | }) 64 | 65 | const diagnostics = await lintSource(view) 66 | 67 | expect(diagnostics).toStrictEqual([]) 68 | }) 69 | 70 | test('handle undefined', async () => { 71 | const view = new EditorView({ 72 | doc: 'Text\n', 73 | extensions: [textDocument()] 74 | }) 75 | 76 | const lintSource = createLintSource({ 77 | doDiagnostics() { 78 | // Do nothing 79 | } 80 | }) 81 | 82 | const diagnostics = await lintSource(view) 83 | 84 | expect(diagnostics).toStrictEqual([]) 85 | }) 86 | 87 | test('severity', async () => { 88 | const view = new EditorView({ 89 | doc: 'Default\nError\nWarning\nInfo\nHint\n', 90 | extensions: [textDocument()] 91 | }) 92 | 93 | const lintSource = createLintSource({ 94 | *doDiagnostics() { 95 | yield { 96 | message: 'Default severity', 97 | range: { 98 | start: { line: 0, character: 0 }, 99 | end: { line: 0, character: 1000 } 100 | } 101 | } 102 | yield { 103 | message: 'Error severity', 104 | severity: DiagnosticSeverity.Error, 105 | range: { 106 | start: { line: 1, character: 0 }, 107 | end: { line: 1, character: 1000 } 108 | } 109 | } 110 | yield { 111 | message: 'Warning severity', 112 | severity: DiagnosticSeverity.Warning, 113 | range: { 114 | start: { line: 2, character: 0 }, 115 | end: { line: 2, character: 1000 } 116 | } 117 | } 118 | yield { 119 | message: 'Info severity', 120 | severity: DiagnosticSeverity.Information, 121 | range: { 122 | start: { line: 3, character: 0 }, 123 | end: { line: 3, character: 1000 } 124 | } 125 | } 126 | yield { 127 | message: 'Hint severity', 128 | severity: DiagnosticSeverity.Hint, 129 | range: { 130 | start: { line: 4, character: 0 }, 131 | end: { line: 4, character: 1000 } 132 | } 133 | } 134 | } 135 | }) 136 | 137 | const diagnostics = await lintSource(view) 138 | 139 | expect(diagnostics).toStrictEqual([ 140 | { 141 | from: 0, 142 | markClass: '', 143 | message: 'Default severity', 144 | renderMessage: undefined, 145 | severity: 'error', 146 | source: undefined, 147 | to: 8 148 | }, 149 | { 150 | from: 8, 151 | markClass: '', 152 | message: 'Error severity', 153 | renderMessage: undefined, 154 | severity: 'error', 155 | source: undefined, 156 | to: 14 157 | }, 158 | { 159 | from: 14, 160 | markClass: '', 161 | message: 'Warning severity', 162 | renderMessage: undefined, 163 | severity: 'warning', 164 | source: undefined, 165 | to: 22 166 | }, 167 | { 168 | from: 22, 169 | markClass: '', 170 | message: 'Info severity', 171 | renderMessage: undefined, 172 | severity: 'info', 173 | source: undefined, 174 | to: 27 175 | }, 176 | { 177 | from: 27, 178 | markClass: '', 179 | message: 'Hint severity', 180 | renderMessage: undefined, 181 | severity: 'hint', 182 | source: undefined, 183 | to: 32 184 | } 185 | ]) 186 | }) 187 | 188 | test('tags', async () => { 189 | const view = new EditorView({ 190 | doc: 'Unnecessary\nDeprecated\n', 191 | extensions: [textDocument()] 192 | }) 193 | 194 | const lintSource = createLintSource({ 195 | *doDiagnostics() { 196 | yield { 197 | message: 'Unnecessary', 198 | tags: [DiagnosticTag.Unnecessary], 199 | range: { 200 | start: { line: 0, character: 0 }, 201 | end: { line: 0, character: 1000 } 202 | } 203 | } 204 | yield { 205 | message: 'Deprecated', 206 | tags: [DiagnosticTag.Deprecated], 207 | range: { 208 | start: { line: 1, character: 0 }, 209 | end: { line: 1, character: 1000 } 210 | } 211 | } 212 | } 213 | }) 214 | 215 | const diagnostics = await lintSource(view) 216 | 217 | expect(diagnostics).toStrictEqual([ 218 | { 219 | from: 0, 220 | markClass: ' cm-lintRange-unnecessary', 221 | message: 'Unnecessary', 222 | renderMessage: undefined, 223 | severity: 'error', 224 | source: undefined, 225 | to: 12 226 | }, 227 | { 228 | from: 12, 229 | markClass: ' cm-lintRange-deprecated', 230 | message: 'Deprecated', 231 | renderMessage: undefined, 232 | severity: 'error', 233 | source: undefined, 234 | to: 23 235 | } 236 | ]) 237 | }) 238 | 239 | test('custom markClass', async () => { 240 | const view = new EditorView({ 241 | doc: 'Text\n', 242 | extensions: [textDocument()] 243 | }) 244 | 245 | const lintSource = createLintSource({ 246 | markClass: 'custom-class', 247 | *doDiagnostics() { 248 | yield { 249 | message: 'Diagnostic', 250 | range: { 251 | start: { line: 0, character: 0 }, 252 | end: { line: 0, character: 4 } 253 | } 254 | } 255 | } 256 | }) 257 | 258 | const diagnostics = await lintSource(view) 259 | 260 | expect(diagnostics).toStrictEqual([ 261 | { 262 | from: 0, 263 | markClass: 'custom-class', 264 | message: 'Diagnostic', 265 | renderMessage: undefined, 266 | severity: 'error', 267 | source: undefined, 268 | to: 4 269 | } 270 | ]) 271 | }) 272 | 273 | test('source', async () => { 274 | const view = new EditorView({ 275 | doc: 'Wrod\n', 276 | extensions: [textDocument()] 277 | }) 278 | 279 | const lintSource = createLintSource({ 280 | *doDiagnostics() { 281 | yield { 282 | message: 'Misspelled word “wrod”', 283 | source: 'spell', 284 | code: 'incorrect', 285 | range: { 286 | start: { line: 0, character: 0 }, 287 | end: { line: 0, character: 4 } 288 | } 289 | } 290 | } 291 | }) 292 | 293 | const diagnostics = await lintSource(view) 294 | 295 | expect(diagnostics).toStrictEqual([ 296 | { 297 | from: 0, 298 | markClass: '', 299 | message: 'Misspelled word “wrod”', 300 | renderMessage: undefined, 301 | severity: 'error', 302 | source: 'spell:incorrect', 303 | to: 4 304 | } 305 | ]) 306 | }) 307 | 308 | test('source only source', async () => { 309 | const view = new EditorView({ 310 | doc: 'Wrod\n', 311 | extensions: [textDocument()] 312 | }) 313 | 314 | const lintSource = createLintSource({ 315 | *doDiagnostics() { 316 | yield { 317 | message: 'Misspelled word “wrod”', 318 | source: 'spell', 319 | range: { 320 | start: { line: 0, character: 0 }, 321 | end: { line: 0, character: 4 } 322 | } 323 | } 324 | } 325 | }) 326 | 327 | const diagnostics = await lintSource(view) 328 | 329 | expect(diagnostics).toStrictEqual([ 330 | { 331 | from: 0, 332 | markClass: '', 333 | message: 'Misspelled word “wrod”', 334 | renderMessage: undefined, 335 | severity: 'error', 336 | source: 'spell', 337 | to: 4 338 | } 339 | ]) 340 | }) 341 | 342 | test('source only code', async () => { 343 | const view = new EditorView({ 344 | doc: 'Wrod\n', 345 | extensions: [textDocument()] 346 | }) 347 | 348 | const lintSource = createLintSource({ 349 | *doDiagnostics() { 350 | yield { 351 | message: 'Misspelled word “wrod”', 352 | code: 'misspell', 353 | range: { 354 | start: { line: 0, character: 0 }, 355 | end: { line: 0, character: 4 } 356 | } 357 | } 358 | } 359 | }) 360 | 361 | const diagnostics = await lintSource(view) 362 | 363 | expect(diagnostics).toStrictEqual([ 364 | { 365 | from: 0, 366 | markClass: '', 367 | message: 'Misspelled word “wrod”', 368 | renderMessage: undefined, 369 | severity: 'error', 370 | source: 'misspell', 371 | to: 4 372 | } 373 | ]) 374 | }) 375 | 376 | test('source custom format', async () => { 377 | const view = new EditorView({ 378 | doc: 'Wrod\n', 379 | extensions: [textDocument()] 380 | }) 381 | 382 | const lintSource = createLintSource({ 383 | formatSource(diagnostic) { 384 | return `${diagnostic.source}(${diagnostic.code})` 385 | }, 386 | *doDiagnostics() { 387 | yield { 388 | message: 'Misspelled word “wrod”', 389 | source: 'spell', 390 | code: 'incorrect', 391 | range: { 392 | start: { line: 0, character: 0 }, 393 | end: { line: 0, character: 4 } 394 | } 395 | } 396 | } 397 | }) 398 | 399 | const diagnostics = await lintSource(view) 400 | 401 | expect(diagnostics).toStrictEqual([ 402 | { 403 | from: 0, 404 | markClass: '', 405 | message: 'Misspelled word “wrod”', 406 | renderMessage: undefined, 407 | severity: 'error', 408 | source: 'spell(incorrect)', 409 | to: 4 410 | } 411 | ]) 412 | }) 413 | 414 | test('codeDescription', async () => { 415 | const view = new EditorView({ 416 | doc: 'Wrod\n', 417 | extensions: [textDocument()] 418 | }) 419 | 420 | const lintSource = createLintSource({ 421 | formatSource(diagnostic) { 422 | return `${diagnostic.source}(${diagnostic.code})` 423 | }, 424 | *doDiagnostics() { 425 | yield { 426 | message: 'Misspelled word “wrod”', 427 | codeDescription: { href: 'https://example.com' }, 428 | range: { 429 | start: { line: 0, character: 0 }, 430 | end: { line: 0, character: 4 } 431 | } 432 | } 433 | } 434 | }) 435 | 436 | const diagnostics = await lintSource(view) 437 | 438 | expect(diagnostics[0].renderMessage!(view)).toMatchInlineSnapshot(` 439 | 440 | Misspelled word “wrod” 441 |
442 | 445 | https://example.com 446 | 447 |
448 | `) 449 | }) 450 | -------------------------------------------------------------------------------- /test/completion.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompletionContext, 3 | type CompletionInfo, 4 | hasNextSnippetField 5 | } from '@codemirror/autocomplete' 6 | import { EditorView } from '@codemirror/view' 7 | import { createCompletionSource, getTextDocument, textDocument } from 'codemirror-languageservice' 8 | import { expect, test } from 'vitest' 9 | import { 10 | CompletionItemKind, 11 | CompletionTriggerKind, 12 | InsertTextFormat, 13 | type CompletionContext as LspCompletionContext, 14 | type Position 15 | } from 'vscode-languageserver-protocol' 16 | import { type TextDocument } from 'vscode-languageserver-textdocument' 17 | 18 | import { markdownToDom } from './utils.js' 19 | 20 | test('completion args explicit', async () => { 21 | let document: TextDocument | undefined 22 | let position: Position | undefined 23 | let context: LspCompletionContext | undefined 24 | 25 | const view = new EditorView({ 26 | doc: 'Text\n', 27 | extensions: [textDocument()] 28 | }) 29 | 30 | const completionSource = createCompletionSource({ 31 | markdownToDom, 32 | doComplete(doc, pos, ctx) { 33 | document = doc 34 | position = pos 35 | context = ctx 36 | } 37 | }) 38 | 39 | await completionSource(new CompletionContext(view.state, 3, true)) 40 | 41 | expect(document).toBe(getTextDocument(view.state)) 42 | expect(position).toStrictEqual({ line: 0, character: 3 }) 43 | expect(context).toStrictEqual({ triggerKind: CompletionTriggerKind.Invoked }) 44 | }) 45 | 46 | test('completion args implicit trigger character match', async () => { 47 | let document: TextDocument | undefined 48 | let position: Position | undefined 49 | let context: LspCompletionContext | undefined 50 | 51 | const view = new EditorView({ 52 | doc: '!@#$\n', 53 | extensions: [textDocument()] 54 | }) 55 | 56 | const completionSource = createCompletionSource({ 57 | markdownToDom, 58 | triggerCharacters: '@#', 59 | doComplete(doc, pos, ctx) { 60 | document = doc 61 | position = pos 62 | context = ctx 63 | } 64 | }) 65 | 66 | await completionSource(new CompletionContext(view.state, 3, false)) 67 | 68 | expect(document).toBe(getTextDocument(view.state)) 69 | expect(position).toStrictEqual({ line: 0, character: 3 }) 70 | expect(context).toStrictEqual({ 71 | triggerCharacter: '#', 72 | triggerKind: CompletionTriggerKind.TriggerCharacter 73 | }) 74 | }) 75 | 76 | test('completion args implicit identifier match', async () => { 77 | let document: TextDocument | undefined 78 | let position: Position | undefined 79 | let context: LspCompletionContext | undefined 80 | 81 | const view = new EditorView({ 82 | doc: 'Text\n', 83 | extensions: [textDocument()] 84 | }) 85 | 86 | const completionSource = createCompletionSource({ 87 | markdownToDom, 88 | doComplete(doc, pos, ctx) { 89 | document = doc 90 | position = pos 91 | context = ctx 92 | } 93 | }) 94 | 95 | await completionSource(new CompletionContext(view.state, 3, false)) 96 | 97 | expect(document).toBe(getTextDocument(view.state)) 98 | expect(position).toStrictEqual({ line: 0, character: 3 }) 99 | expect(context).toStrictEqual({ 100 | triggerCharacter: 'x', 101 | triggerKind: CompletionTriggerKind.TriggerCharacter 102 | }) 103 | }) 104 | 105 | test('completion args implicit identifier no match', async () => { 106 | let document: TextDocument | undefined 107 | let position: Position | undefined 108 | let context: LspCompletionContext | undefined 109 | 110 | const view = new EditorView({ 111 | doc: '!@#$\n', 112 | extensions: [textDocument()] 113 | }) 114 | 115 | const completionSource = createCompletionSource({ 116 | markdownToDom, 117 | triggerCharacters: '%^&*', 118 | doComplete(doc, pos, ctx) { 119 | document = doc 120 | position = pos 121 | context = ctx 122 | } 123 | }) 124 | 125 | await completionSource(new CompletionContext(view.state, 3, false)) 126 | 127 | expect(document).toBeUndefined() 128 | expect(position).toBeUndefined() 129 | expect(context).toBeUndefined() 130 | }) 131 | 132 | test('ignore null', async () => { 133 | const view = new EditorView({ 134 | doc: 'Text\n', 135 | extensions: [textDocument()] 136 | }) 137 | 138 | const completionSource = createCompletionSource({ 139 | markdownToDom, 140 | doComplete() { 141 | return null 142 | } 143 | }) 144 | 145 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 146 | 147 | expect(completions).toBeNull() 148 | }) 149 | 150 | test('ignore undefined', async () => { 151 | const view = new EditorView({ 152 | doc: 'Text\n', 153 | extensions: [textDocument()] 154 | }) 155 | 156 | const completionSource = createCompletionSource({ 157 | markdownToDom, 158 | doComplete() { 159 | // Do nothing 160 | } 161 | }) 162 | 163 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 164 | 165 | expect(completions).toBeNull() 166 | }) 167 | 168 | test('ignore outdated', async () => { 169 | const view = new EditorView({ 170 | doc: 'Text\n', 171 | extensions: [textDocument()] 172 | }) 173 | 174 | const completionSource = createCompletionSource({ 175 | markdownToDom, 176 | doComplete() { 177 | view.dispatch({ changes: [{ from: 0, to: 4, insert: 'Updated' }] }) 178 | return [] 179 | } 180 | }) 181 | 182 | const completions = await completionSource(new CompletionContext(view.state, 3, true, view)) 183 | 184 | expect(completions).toBeNull() 185 | }) 186 | 187 | test('minimal meta', async () => { 188 | const view = new EditorView({ 189 | doc: 'Com\n', 190 | extensions: [textDocument()] 191 | }) 192 | 193 | const completionSource = createCompletionSource({ 194 | markdownToDom, 195 | *doComplete() { 196 | yield { 197 | label: 'pletion' 198 | } 199 | } 200 | }) 201 | 202 | const completions = await completionSource(new CompletionContext(view.state, 4, true)) 203 | 204 | expect(completions).toStrictEqual({ 205 | commitCharacters: undefined, 206 | from: 4, 207 | options: [ 208 | { 209 | commitCharacters: undefined, 210 | detail: undefined, 211 | info: undefined, 212 | label: 'pletion', 213 | section: undefined, 214 | type: undefined 215 | } 216 | ], 217 | to: 4 218 | }) 219 | }) 220 | 221 | test('full meta', async () => { 222 | const view = new EditorView({ 223 | doc: 'Com\n', 224 | extensions: [textDocument()] 225 | }) 226 | 227 | const completionSource = createCompletionSource({ 228 | markdownToDom, 229 | *doComplete() { 230 | yield { 231 | commitCharacters: ['p'], 232 | detail: 'word', 233 | documentation: 'Autocomplete to “Completion”', 234 | label: 'pletion' 235 | } 236 | } 237 | }) 238 | 239 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 240 | 241 | expect(completions).toStrictEqual({ 242 | commitCharacters: undefined, 243 | from: 3, 244 | options: [ 245 | { 246 | commitCharacters: ['p'], 247 | detail: 'word', 248 | info: expect.any(Function), 249 | label: 'pletion', 250 | section: undefined, 251 | type: undefined 252 | } 253 | ], 254 | to: 3 255 | }) 256 | 257 | const completion = completions!.options[0]! 258 | const info = (completion.info as () => CompletionInfo)() 259 | expect(info).toMatchInlineSnapshot(` 260 | 261 |

262 | Autocomplete to “Completion” 263 |

264 |
265 | `) 266 | }) 267 | 268 | test('completion item kinds', async () => { 269 | const view = new EditorView({ 270 | doc: 'Comp\n', 271 | extensions: [textDocument()] 272 | }) 273 | 274 | const completionSource = createCompletionSource({ 275 | markdownToDom, 276 | *doComplete() { 277 | yield { 278 | label: 'Text', 279 | kind: CompletionItemKind.Text 280 | } 281 | yield { 282 | label: 'Method', 283 | kind: CompletionItemKind.Method 284 | } 285 | yield { 286 | label: 'Function', 287 | kind: CompletionItemKind.Function 288 | } 289 | yield { 290 | label: 'Constructor', 291 | kind: CompletionItemKind.Constructor 292 | } 293 | yield { 294 | label: 'Field', 295 | kind: CompletionItemKind.Field 296 | } 297 | yield { 298 | label: 'Variable', 299 | kind: CompletionItemKind.Variable 300 | } 301 | yield { 302 | label: 'Class', 303 | kind: CompletionItemKind.Class 304 | } 305 | yield { 306 | label: 'Interface', 307 | kind: CompletionItemKind.Interface 308 | } 309 | yield { 310 | label: 'Module', 311 | kind: CompletionItemKind.Module 312 | } 313 | yield { 314 | label: 'Property', 315 | kind: CompletionItemKind.Property 316 | } 317 | yield { 318 | label: 'Unit', 319 | kind: CompletionItemKind.Unit 320 | } 321 | yield { 322 | label: 'Value', 323 | kind: CompletionItemKind.Value 324 | } 325 | yield { 326 | label: 'Enum', 327 | kind: CompletionItemKind.Enum 328 | } 329 | yield { 330 | label: 'Keyword', 331 | kind: CompletionItemKind.Keyword 332 | } 333 | yield { 334 | label: 'Snippet', 335 | kind: CompletionItemKind.Snippet 336 | } 337 | yield { 338 | label: 'Color', 339 | kind: CompletionItemKind.Color 340 | } 341 | yield { 342 | label: 'File', 343 | kind: CompletionItemKind.File 344 | } 345 | yield { 346 | label: 'Reference', 347 | kind: CompletionItemKind.Reference 348 | } 349 | yield { 350 | label: 'Folder', 351 | kind: CompletionItemKind.Folder 352 | } 353 | yield { 354 | label: 'EnumMember', 355 | kind: CompletionItemKind.EnumMember 356 | } 357 | yield { 358 | label: 'Constant', 359 | kind: CompletionItemKind.Constant 360 | } 361 | yield { 362 | label: 'Struct', 363 | kind: CompletionItemKind.Struct 364 | } 365 | yield { 366 | label: 'Event', 367 | kind: CompletionItemKind.Event 368 | } 369 | yield { 370 | label: 'Operator', 371 | kind: CompletionItemKind.Operator 372 | } 373 | yield { 374 | label: 'TypeParameter', 375 | kind: CompletionItemKind.TypeParameter 376 | } 377 | } 378 | }) 379 | 380 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 381 | 382 | expect(completions).toStrictEqual({ 383 | commitCharacters: undefined, 384 | from: 3, 385 | options: [ 386 | { 387 | commitCharacters: undefined, 388 | detail: undefined, 389 | info: undefined, 390 | label: 'Text', 391 | section: undefined, 392 | type: 'text' 393 | }, 394 | { 395 | commitCharacters: undefined, 396 | detail: undefined, 397 | info: undefined, 398 | label: 'Method', 399 | section: undefined, 400 | type: 'method' 401 | }, 402 | { 403 | commitCharacters: undefined, 404 | detail: undefined, 405 | info: undefined, 406 | label: 'Function', 407 | section: undefined, 408 | type: 'function' 409 | }, 410 | { 411 | commitCharacters: undefined, 412 | detail: undefined, 413 | info: undefined, 414 | label: 'Constructor', 415 | section: undefined, 416 | type: 'class' 417 | }, 418 | { 419 | commitCharacters: undefined, 420 | detail: undefined, 421 | info: undefined, 422 | label: 'Field', 423 | section: undefined, 424 | type: 'property' 425 | }, 426 | { 427 | commitCharacters: undefined, 428 | detail: undefined, 429 | info: undefined, 430 | label: 'Variable', 431 | section: undefined, 432 | type: 'variable' 433 | }, 434 | { 435 | commitCharacters: undefined, 436 | detail: undefined, 437 | info: undefined, 438 | label: 'Class', 439 | section: undefined, 440 | type: 'class' 441 | }, 442 | { 443 | commitCharacters: undefined, 444 | detail: undefined, 445 | info: undefined, 446 | label: 'Interface', 447 | section: undefined, 448 | type: 'type' 449 | }, 450 | { 451 | commitCharacters: undefined, 452 | detail: undefined, 453 | info: undefined, 454 | label: 'Module', 455 | section: undefined, 456 | type: 'namespace' 457 | }, 458 | { 459 | commitCharacters: undefined, 460 | detail: undefined, 461 | info: undefined, 462 | label: 'Property', 463 | section: undefined, 464 | type: 'property' 465 | }, 466 | { 467 | commitCharacters: undefined, 468 | detail: undefined, 469 | info: undefined, 470 | label: 'Unit', 471 | section: undefined, 472 | type: 'keyword' 473 | }, 474 | { 475 | commitCharacters: undefined, 476 | detail: undefined, 477 | info: undefined, 478 | label: 'Value', 479 | section: undefined, 480 | type: 'variable' 481 | }, 482 | { 483 | commitCharacters: undefined, 484 | detail: undefined, 485 | info: undefined, 486 | label: 'Enum', 487 | section: undefined, 488 | type: 'enum' 489 | }, 490 | { 491 | commitCharacters: undefined, 492 | detail: undefined, 493 | info: undefined, 494 | label: 'Keyword', 495 | section: undefined, 496 | type: 'keyword' 497 | }, 498 | { 499 | commitCharacters: undefined, 500 | detail: undefined, 501 | info: undefined, 502 | label: 'Snippet', 503 | section: undefined, 504 | type: 'text' 505 | }, 506 | { 507 | commitCharacters: undefined, 508 | detail: undefined, 509 | info: undefined, 510 | label: 'Color', 511 | section: undefined, 512 | type: 'constant' 513 | }, 514 | { 515 | commitCharacters: undefined, 516 | detail: undefined, 517 | info: undefined, 518 | label: 'File', 519 | section: undefined, 520 | type: undefined 521 | }, 522 | { 523 | commitCharacters: undefined, 524 | detail: undefined, 525 | info: undefined, 526 | label: 'Reference', 527 | section: undefined, 528 | type: 'variable' 529 | }, 530 | { 531 | commitCharacters: undefined, 532 | detail: undefined, 533 | info: undefined, 534 | label: 'Folder', 535 | section: undefined, 536 | type: undefined 537 | }, 538 | { 539 | commitCharacters: undefined, 540 | detail: undefined, 541 | info: undefined, 542 | label: 'EnumMember', 543 | section: undefined, 544 | type: 'enum' 545 | }, 546 | { 547 | commitCharacters: undefined, 548 | detail: undefined, 549 | info: undefined, 550 | label: 'Constant', 551 | section: undefined, 552 | type: 'constant' 553 | }, 554 | { 555 | commitCharacters: undefined, 556 | detail: undefined, 557 | info: undefined, 558 | label: 'Struct', 559 | section: undefined, 560 | type: 'type' 561 | }, 562 | { 563 | commitCharacters: undefined, 564 | detail: undefined, 565 | info: undefined, 566 | label: 'Event', 567 | section: undefined, 568 | type: 'variable' 569 | }, 570 | { 571 | commitCharacters: undefined, 572 | detail: undefined, 573 | info: undefined, 574 | label: 'Operator', 575 | section: undefined, 576 | type: 'keyword' 577 | }, 578 | { 579 | commitCharacters: undefined, 580 | detail: undefined, 581 | info: undefined, 582 | label: 'TypeParameter', 583 | section: undefined, 584 | type: 'type' 585 | } 586 | ], 587 | to: 3 588 | }) 589 | }) 590 | 591 | test('textEditText', async () => { 592 | const view = new EditorView({ 593 | doc: 'Com\n', 594 | extensions: [textDocument()] 595 | }) 596 | 597 | const completionSource = createCompletionSource({ 598 | markdownToDom, 599 | *doComplete() { 600 | yield { 601 | label: 'completion', 602 | textEditText: 'pletion' 603 | } 604 | } 605 | }) 606 | 607 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 608 | 609 | expect(completions).toStrictEqual({ 610 | commitCharacters: undefined, 611 | from: 3, 612 | options: [ 613 | { 614 | apply: 'pletion', 615 | commitCharacters: undefined, 616 | detail: undefined, 617 | info: undefined, 618 | label: 'completion', 619 | section: undefined, 620 | type: undefined 621 | } 622 | ], 623 | to: 3 624 | }) 625 | }) 626 | 627 | test('textEdit plain text', async () => { 628 | const view = new EditorView({ 629 | doc: 'Commm\n', 630 | extensions: [textDocument()] 631 | }) 632 | 633 | const completionSource = createCompletionSource({ 634 | markdownToDom, 635 | *doComplete() { 636 | yield { 637 | label: 'completion', 638 | textEdit: { 639 | newText: 'Completion', 640 | range: { 641 | start: { line: 0, character: 0 }, 642 | end: { line: 0, character: 5 } 643 | } 644 | } 645 | } 646 | yield { 647 | label: 'completion', 648 | textEdit: { 649 | newText: 'Completion', 650 | range: { 651 | start: { line: 0, character: 0 }, 652 | end: { line: 0, character: 5 } 653 | } 654 | } 655 | } 656 | } 657 | }) 658 | 659 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 660 | 661 | const apply = completions!.options[0]!.apply as (v: EditorView) => unknown 662 | apply(view) 663 | 664 | expect(completions?.from).toBe(0) 665 | expect(completions?.to).toBe(5) 666 | expect(String(view.state.doc)).toBe('Completion\n') 667 | }) 668 | 669 | test('textEdit snippet', async () => { 670 | const view = new EditorView({ 671 | doc: '\n', 672 | extensions: [textDocument()] 673 | }) 674 | 675 | const completionSource = createCompletionSource({ 676 | markdownToDom, 677 | *doComplete() { 678 | yield { 679 | label: 'loop', 680 | insertTextFormat: InsertTextFormat.Snippet, 681 | textEdit: { 682 | newText: 'for (let ${0} = 0; ${0} < ${1}; $0++) { ${1} }', 683 | insert: { 684 | start: { line: 0, character: 0 }, 685 | end: { line: 0, character: 0 } 686 | }, 687 | replace: { 688 | start: { line: 0, character: 0 }, 689 | end: { line: 0, character: 0 } 690 | } 691 | } 692 | } 693 | } 694 | }) 695 | 696 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 697 | 698 | const apply = completions!.options[0]!.apply as (v: EditorView) => unknown 699 | apply(view) 700 | expect(hasNextSnippetField(view.state)).toBe(true) 701 | expect(String(view.state.doc)).toBe('for (let = 0; < ; ++) { }\n') 702 | }) 703 | 704 | test('itemDefaults', async () => { 705 | const view = new EditorView({ 706 | doc: '\n', 707 | extensions: [textDocument()] 708 | }) 709 | 710 | const completionSource = createCompletionSource({ 711 | markdownToDom, 712 | doComplete() { 713 | return { 714 | isIncomplete: false, 715 | itemDefaults: { 716 | commitCharacters: ['a'], 717 | insertTextFormat: InsertTextFormat.Snippet 718 | }, 719 | items: [ 720 | { 721 | label: 'loop', 722 | textEdit: { 723 | newText: 'for (let ${0} = 0; ${0} < ${1}; $0++) { ${1} }', 724 | range: { 725 | start: { line: 0, character: 0 }, 726 | end: { line: 0, character: 0 } 727 | } 728 | } 729 | } 730 | ] 731 | } 732 | } 733 | }) 734 | 735 | const completions = await completionSource(new CompletionContext(view.state, 3, true)) 736 | 737 | const apply = completions!.options[0]!.apply as (v: EditorView) => unknown 738 | apply(view) 739 | expect(hasNextSnippetField(view.state)).toBe(true) 740 | expect(String(view.state.doc)).toBe('for (let = 0; < ; ++) { }\n') 741 | }) 742 | --------------------------------------------------------------------------------