├── .gitignore ├── .watchmanconfig ├── justfile ├── .github ├── ISSUE_TEMPLATE │ └── issue.md ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── .npmignore ├── tsconfig.json ├── esbuild.mjs ├── src ├── features │ ├── linters │ │ ├── pyflakes.ts │ │ ├── pycodestyle.ts │ │ ├── mypy.ts │ │ ├── pylama.ts │ │ ├── flake8.ts │ │ ├── pylint.ts │ │ ├── bandit.ts │ │ ├── prospector.ts │ │ ├── linterInfo.ts │ │ ├── pydocstyle.ts │ │ ├── pytype.ts │ │ ├── ruff.ts │ │ ├── baseLinter.ts │ │ └── lintingEngine.ts │ ├── formatters │ │ ├── ruff.ts │ │ ├── pyink.ts │ │ ├── black.ts │ │ ├── autopep8.ts │ │ ├── darker.ts │ │ ├── yapf.ts │ │ ├── blackd.ts │ │ └── baseFormatter.ts │ ├── importCompletion.ts │ ├── lintting.ts │ ├── semanticTokens.ts │ ├── sortImports.ts │ ├── testing.ts │ ├── formatting.ts │ ├── codeAction.ts │ ├── inlayHints.ts │ └── refactor.ts ├── parsers │ ├── index.ts │ ├── testFramework.ts │ ├── inlayHints.ts │ └── semanticTokens.ts ├── async.ts ├── commands.ts ├── systemVariables.ts ├── types.ts ├── middleware.ts ├── processService.ts ├── index.ts ├── utils.ts └── configSettings.ts ├── diff.mjs ├── LICENSE ├── biome.json ├── README.md └── pythonFiles └── refactor.py /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": [ 3 | "node_modules" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | build: 2 | node esbuild.mjs 3 | 4 | install: 5 | npm install 6 | 7 | lint: 8 | npm run lint 9 | 10 | watch: 11 | node esbuild.mjs --watch 12 | 13 | clean: 14 | rm -rf ./node_modules ./lib 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: issue 3 | about: issue 4 | title: '' 5 | labels: '' 6 | assignees: fannheyward 7 | 8 | --- 9 | 10 | **What's the output of `:CocCommand pyright.version`** 11 | 12 | 13 | **What's the output of `:CocCommand workspace.showOutput Pyright`** 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | tsconfig.json 4 | *.map 5 | *.test.js 6 | .tags 7 | .DS_Store 8 | .eslintrc.js 9 | .github 10 | .watchmanconfig 11 | .idea 12 | webpack.config.js 13 | yarn.lock 14 | yarn-error.log 15 | build_server.sh 16 | diff.mjs 17 | esbuild.mjs 18 | justfile 19 | biome.json 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | open-pull-requests-limit: 10 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | groups: 9 | dev-dependencies-ts-eslint: 10 | patterns: 11 | - "@typescript-eslint/*" 12 | reviewers: 13 | - fannheyward 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "es2017", 5 | "lib": ["es2017", "es2018"], 6 | "module": "ES2020", 7 | "declaration": false, 8 | "sourceMap": true, 9 | "outDir": "lib", 10 | "strict": true, 11 | "moduleResolution": "Bundler", 12 | "noImplicitAny": false, 13 | "esModuleInterop": true 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /esbuild.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | 3 | const options = { 4 | entryPoints: ['src/index.ts'], 5 | bundle: true, 6 | sourcemap: false, 7 | mainFields: ['module', 'main'], 8 | external: ['coc.nvim'], 9 | platform: 'node', 10 | target: 'node16', 11 | outfile: 'lib/index.js', 12 | }; 13 | 14 | if (process.argv.length > 2 && process.argv[2] === '--watch') { 15 | const ctx = await esbuild.context(options); 16 | await ctx.watch(); 17 | console.log('watching...'); 18 | } else { 19 | const result = await esbuild.build(options); 20 | if (result.errors.length) { 21 | console.error(result.errors); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | node-version: [20] 19 | 20 | env: 21 | NODE_ENV: test 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - name: npm ci 30 | run: | 31 | npm ci 32 | - name: npm lint 33 | run: | 34 | npm run lint 35 | -------------------------------------------------------------------------------- /src/features/linters/pyflakes.ts: -------------------------------------------------------------------------------- 1 | import { Uri, type CancellationToken, type TextDocument } from 'coc.nvim'; 2 | import { LintMessageSeverity, type ILintMessage } from '../../types'; 3 | import { BaseLinter } from './baseLinter'; 4 | 5 | const REGEX = '(?.*.py):(?\\d+):(?\\d+): (?.*)\\r?(\\n|$)'; 6 | 7 | export class Pyflakes extends BaseLinter { 8 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 9 | const messages = await this.run([Uri.parse(document.uri).fsPath], document, cancellation, REGEX); 10 | for (const msg of messages) { 11 | msg.severity = LintMessageSeverity.Warning; 12 | } 13 | 14 | return messages; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/parsers/index.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticSink } from '@zzzen/pyright-internal/dist/common/diagnosticSink'; 2 | import { ParseOptions, type ParseFileResults, Parser } from '@zzzen/pyright-internal/dist/parser/parser'; 3 | import { TypeInlayHintsWalker } from './inlayHints'; 4 | import { SemanticTokensWalker } from './semanticTokens'; 5 | import { FunctionFormatItemType, TestFrameworkWalker } from './testFramework'; 6 | 7 | function parse(source: string) { 8 | let result: ParseFileResults | undefined; 9 | const parserOptions = new ParseOptions(); 10 | const diagSink = new DiagnosticSink(); 11 | const parser = new Parser(); 12 | try { 13 | result = parser.parseSourceFile(source, parserOptions, diagSink); 14 | } catch (_e) {} 15 | return result; 16 | } 17 | 18 | export { parse, SemanticTokensWalker, TestFrameworkWalker, TypeInlayHintsWalker, FunctionFormatItemType }; 19 | -------------------------------------------------------------------------------- /src/features/linters/pycodestyle.ts: -------------------------------------------------------------------------------- 1 | import { type CancellationToken, type OutputChannel, type TextDocument, Uri } from 'coc.nvim'; 2 | import type { ILinterInfo, ILintMessage } from '../../types'; 3 | import { BaseLinter } from './baseLinter'; 4 | 5 | const COLUMN_OFF_SET = 1; 6 | 7 | export class PyCodeStyle extends BaseLinter { 8 | constructor(info: ILinterInfo, outputChannel: OutputChannel) { 9 | super(info, outputChannel, COLUMN_OFF_SET); 10 | } 11 | 12 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 13 | const messages = await this.run( 14 | ['--format=%(row)d,%(col)d,%(code).1s,%(code)s:%(text)s', Uri.parse(document.uri).fsPath], 15 | document, 16 | cancellation, 17 | ); 18 | for (const msg of messages) { 19 | msg.severity = this.parseMessagesSeverity(msg.type, this.pythonSettings.linting.pycodestyleCategorySeverity); 20 | } 21 | return messages; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /diff.mjs: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs'; 2 | import { promisify } from 'util'; 3 | 4 | async function diff() { 5 | const text = await promisify(readFile)('./package.json'); 6 | const config = JSON.parse(text.toString()); 7 | const overrides = config.contributes.configuration.properties['python.analysis.diagnosticSeverityOverrides'].properties; 8 | 9 | const resp = await fetch('https://raw.githubusercontent.com/microsoft/pyright/main/packages/vscode-pyright/schemas/pyrightconfig.schema.json'); 10 | const schema = await resp.json(); 11 | for (const [key, val] of Object.entries(schema.properties)) { 12 | if (val['$ref'] === '#/definitions/diagnostic') { 13 | if (!overrides[key]) { 14 | console.error('missing:', key); 15 | } else { 16 | const obj = overrides[key]; 17 | if (obj.default !== val.default) { 18 | console.error(`${key}, package.json value: ${obj.default}, schema value: ${val.default}`); 19 | } 20 | } 21 | } 22 | } 23 | } 24 | 25 | await diff(); 26 | -------------------------------------------------------------------------------- /src/features/linters/mypy.ts: -------------------------------------------------------------------------------- 1 | import { type CancellationToken, type OutputChannel, type TextDocument, Uri } from 'coc.nvim'; 2 | import type { ILinterInfo, ILintMessage } from '../../types'; 3 | import { BaseLinter } from './baseLinter'; 4 | 5 | const COLUMN_OFF_SET = 1; 6 | const REGEX = '(?[^:]+):(?\\d+)(:(?\\d+))?: (?\\w+): (?.*)\\r?(\\n|$)'; 7 | 8 | export class MyPy extends BaseLinter { 9 | constructor(info: ILinterInfo, outputChannel: OutputChannel) { 10 | super(info, outputChannel, COLUMN_OFF_SET); 11 | } 12 | 13 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 14 | const args = ['--python-executable', this.pythonSettings.pythonPath, Uri.parse(document.uri).fsPath]; 15 | const messages = await this.run(args, document, cancellation, REGEX); 16 | for (const msg of messages) { 17 | msg.severity = this.parseMessagesSeverity(msg.type, this.pythonSettings.linting.mypyCategorySeverity); 18 | msg.code = msg.type; 19 | } 20 | return messages; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/features/linters/pylama.ts: -------------------------------------------------------------------------------- 1 | import { type CancellationToken, type OutputChannel, type TextDocument, Uri } from 'coc.nvim'; 2 | import { type ILinterInfo, type ILintMessage, LintMessageSeverity } from '../../types'; 3 | import { BaseLinter } from './baseLinter'; 4 | 5 | const REGEX = 6 | '(?.py):(?\\d+):(?\\d+): \\[(?\\w+)\\] (?\\w\\d+):? (?.*)\\r?(\\n|$)'; 7 | const COLUMN_OFF_SET = 1; 8 | 9 | export class Pylama extends BaseLinter { 10 | constructor(info: ILinterInfo, outputChannel: OutputChannel) { 11 | super(info, outputChannel, COLUMN_OFF_SET); 12 | } 13 | 14 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 15 | const messages = await this.run( 16 | ['--format=parsable', Uri.parse(document.uri).fsPath], 17 | document, 18 | cancellation, 19 | REGEX, 20 | ); 21 | // All messages in pylama are treated as warnings for now. 22 | for (const msg of messages) { 23 | msg.severity = LintMessageSeverity.Warning; 24 | } 25 | 26 | return messages; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Heyward Fann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/features/linters/flake8.ts: -------------------------------------------------------------------------------- 1 | import { type CancellationToken, type OutputChannel, type TextDocument, Uri } from 'coc.nvim'; 2 | import type { ILinterInfo, ILintMessage } from '../../types'; 3 | import { BaseLinter } from './baseLinter'; 4 | 5 | const COLUMN_OFF_SET = 1; 6 | 7 | export class Flake8 extends BaseLinter { 8 | constructor(info: ILinterInfo, outputChannel: OutputChannel) { 9 | super(info, outputChannel, COLUMN_OFF_SET); 10 | } 11 | 12 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 13 | const fsPath = Uri.parse(document.uri).fsPath; 14 | const args = ['--format=%(row)d,%(col)d,%(code).1s,%(code)s:%(text)s', '--exit-zero']; 15 | if (this.info.stdinSupport) { 16 | args.push('--stdin-display-name', fsPath, '-'); 17 | } else { 18 | args.push(fsPath); 19 | } 20 | const messages = await this.run(args, document, cancellation); 21 | for (const msg of messages) { 22 | msg.severity = this.parseMessagesSeverity(msg.type, this.pythonSettings.linting.flake8CategorySeverity); 23 | } 24 | return messages; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "indentStyle": "space", 6 | "indentWidth": 2, 7 | "lineWidth": 120 8 | }, 9 | "javascript": { 10 | "formatter": { 11 | "quoteStyle": "single" 12 | } 13 | }, 14 | "assist": { "actions": { "source": { "organizeImports": "off" } } }, 15 | "linter": { 16 | "enabled": true, 17 | "rules": { 18 | "recommended": true, 19 | "complexity": { 20 | "useLiteralKeys": "off" 21 | }, 22 | "style": { 23 | "useNumberNamespace": "off", 24 | "noNonNullAssertion": "off", 25 | "noParameterAssign": "error", 26 | "useAsConstAssertion": "error", 27 | "useDefaultParameterLast": "error", 28 | "useEnumInitializers": "error", 29 | "useSelfClosingElements": "error", 30 | "useSingleVarDeclarator": "error", 31 | "noUnusedTemplateLiteral": "error", 32 | "noInferrableTypes": "error", 33 | "noUselessElse": "error" 34 | }, 35 | "suspicious": { 36 | "noExplicitAny": "off" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/features/formatters/ruff.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CancellationToken, 3 | FormattingOptions, 4 | OutputChannel, 5 | Range, 6 | TextDocument, 7 | TextEdit, 8 | Thenable, 9 | } from 'coc.nvim'; 10 | import type { IPythonSettings } from '../../types'; 11 | import { BaseFormatter } from './baseFormatter'; 12 | 13 | export class RuffFormatter extends BaseFormatter { 14 | constructor( 15 | public readonly pythonSettings: IPythonSettings, 16 | public readonly outputChannel: OutputChannel, 17 | ) { 18 | super('ruff', pythonSettings, outputChannel); 19 | } 20 | 21 | public formatDocument( 22 | document: TextDocument, 23 | options: FormattingOptions, 24 | token: CancellationToken, 25 | range?: Range, 26 | ): Thenable { 27 | const ruffArgs = ['format', '--diff', '--silent']; 28 | if (range) { 29 | ruffArgs.push(`--range=${range.start.line + 1}-${range.end.line + 1}`); 30 | } 31 | if (this.pythonSettings.formatting.ruffArgs.length > 0) { 32 | ruffArgs.push(...this.pythonSettings.formatting.ruffArgs); 33 | } 34 | return super.provideDocumentFormattingEdits(document, options, token, ruffArgs); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/features/linters/pylint.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Uri, type CancellationToken, type TextDocument } from 'coc.nvim'; 5 | import type { ILintMessage } from '../../types'; 6 | import { BaseLinter } from './baseLinter'; 7 | 8 | const REGEX = '(?\\d+),(?-?\\d+),(?\\w+),(?[\\w-]+):(?.*)\\r?(\\n|$)'; 9 | 10 | export class Pylint extends BaseLinter { 11 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 12 | const args = [ 13 | "--msg-template='{line},{column},{category},{symbol}:{msg}'", 14 | '--exit-zero', 15 | '--reports=n', 16 | '--output-format=text', 17 | ]; 18 | if (this.info.stdinSupport) { 19 | args.push('--from-stdin'); 20 | } 21 | args.push(Uri.parse(document.uri).fsPath); 22 | const messages = await this.run(args, document, cancellation, REGEX); 23 | for (const msg of messages) { 24 | msg.severity = this.parseMessagesSeverity(msg.type, this.pythonSettings.linting.pylintCategorySeverity); 25 | } 26 | 27 | return messages; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/features/formatters/pyink.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CancellationToken, 3 | FormattingOptions, 4 | OutputChannel, 5 | Range, 6 | TextDocument, 7 | TextEdit, 8 | Thenable, 9 | } from 'coc.nvim'; 10 | import type { IPythonSettings } from '../../types'; 11 | import { BaseFormatter } from './baseFormatter'; 12 | 13 | export class PyinkFormatter extends BaseFormatter { 14 | constructor( 15 | public readonly pythonSettings: IPythonSettings, 16 | public readonly outputChannel: OutputChannel, 17 | ) { 18 | super('pyink', pythonSettings, outputChannel); 19 | } 20 | 21 | public formatDocument( 22 | document: TextDocument, 23 | options: FormattingOptions, 24 | token: CancellationToken, 25 | range?: Range, 26 | ): Thenable { 27 | const args = ['--diff', '--quiet']; 28 | if (this.pythonSettings.formatting.pyinkArgs.length > 0) { 29 | args.push(...this.pythonSettings.formatting.pyinkArgs); 30 | } 31 | 32 | if (range) { 33 | args.push(`--pyink-lines=${range.start.line + 1}-${range.end.line}`); 34 | } 35 | 36 | const promise = super.provideDocumentFormattingEdits(document, options, token, args); 37 | return promise; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/features/formatters/black.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CancellationToken, 3 | FormattingOptions, 4 | OutputChannel, 5 | Range, 6 | TextDocument, 7 | TextEdit, 8 | Thenable, 9 | } from 'coc.nvim'; 10 | import type { IPythonSettings } from '../../types'; 11 | import { BaseFormatter } from './baseFormatter'; 12 | 13 | export class BlackFormatter extends BaseFormatter { 14 | constructor( 15 | public readonly pythonSettings: IPythonSettings, 16 | public readonly outputChannel: OutputChannel, 17 | ) { 18 | super('black', pythonSettings, outputChannel); 19 | } 20 | 21 | public formatDocument( 22 | document: TextDocument, 23 | options: FormattingOptions, 24 | token: CancellationToken, 25 | range?: Range, 26 | ): Thenable { 27 | const blackArgs = ['--diff', '--quiet']; 28 | if (range) { 29 | blackArgs.push(`--line-ranges=${range.start.line + 1}-${range.end.line}`); 30 | } 31 | if (this.pythonSettings.formatting.blackArgs.length > 0) { 32 | blackArgs.push(...this.pythonSettings.formatting.blackArgs); 33 | } 34 | const promise = super.provideDocumentFormattingEdits(document, options, token, blackArgs); 35 | return promise; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/features/formatters/autopep8.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TextDocument, 3 | FormattingOptions, 4 | CancellationToken, 5 | Range, 6 | Thenable, 7 | TextEdit, 8 | OutputChannel, 9 | } from 'coc.nvim'; 10 | import type { IPythonSettings } from '../../types'; 11 | import { BaseFormatter } from './baseFormatter'; 12 | 13 | export class AutoPep8Formatter extends BaseFormatter { 14 | constructor( 15 | public readonly pythonSettings: IPythonSettings, 16 | public readonly outputChannel: OutputChannel, 17 | ) { 18 | super('autopep8', pythonSettings, outputChannel); 19 | } 20 | 21 | public formatDocument( 22 | document: TextDocument, 23 | options: FormattingOptions, 24 | token: CancellationToken, 25 | range?: Range, 26 | ): Thenable { 27 | const autoPep8Args = ['--diff']; 28 | if (this.pythonSettings.formatting.autopep8Args.length > 0) { 29 | autoPep8Args.push(...this.pythonSettings.formatting.autopep8Args); 30 | } 31 | if (range) { 32 | autoPep8Args.push('--line-range', (range.start.line + 1).toString(), (range.end.line + 1).toString()); 33 | } 34 | const promise = super.provideDocumentFormattingEdits(document, options, token, autoPep8Args); 35 | return promise; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/features/formatters/darker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | type FormattingOptions, 4 | type OutputChannel, 5 | type TextDocument, 6 | type TextEdit, 7 | type Thenable, 8 | Uri, 9 | } from 'coc.nvim'; 10 | import type { IPythonSettings } from '../../types'; 11 | import { BaseFormatter } from './baseFormatter'; 12 | 13 | export class DarkerFormatter extends BaseFormatter { 14 | constructor( 15 | public readonly pythonSettings: IPythonSettings, 16 | public readonly outputChannel: OutputChannel, 17 | ) { 18 | super('darker', pythonSettings, outputChannel); 19 | } 20 | 21 | createTempFile(document: TextDocument): Promise { 22 | return new Promise((resolve) => { 23 | resolve(Uri.parse(document.uri).fsPath); 24 | }); 25 | } 26 | 27 | public formatDocument( 28 | document: TextDocument, 29 | options: FormattingOptions, 30 | token: CancellationToken, 31 | ): Thenable { 32 | const darkerArgs = ['--diff']; 33 | if (this.pythonSettings.formatting.darkerArgs.length > 0) { 34 | darkerArgs.push(...this.pythonSettings.formatting.darkerArgs); 35 | } 36 | return super.provideDocumentFormattingEdits(document, options, token, darkerArgs); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/features/linters/bandit.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Uri, type CancellationToken, type TextDocument } from 'coc.nvim'; 5 | import { type ILintMessage, LintMessageSeverity } from '../../types'; 6 | import { BaseLinter } from './baseLinter'; 7 | 8 | const severityMapping: Record = { 9 | LOW: LintMessageSeverity.Information, 10 | MEDIUM: LintMessageSeverity.Warning, 11 | HIGH: LintMessageSeverity.Error, 12 | }; 13 | 14 | export class Bandit extends BaseLinter { 15 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 16 | // View all errors in bandit <= 1.5.1 (https://github.com/PyCQA/bandit/issues/371) 17 | const messages = await this.run( 18 | [ 19 | '-f', 20 | 'custom', 21 | '--msg-template', 22 | '{line},0,{severity},{test_id}:{msg}', 23 | '-n', 24 | '-1', 25 | Uri.parse(document.uri).fsPath, 26 | ], 27 | document, 28 | cancellation, 29 | ); 30 | 31 | for (const msg of messages) { 32 | msg.severity = severityMapping[msg.type]; 33 | } 34 | return messages; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/features/formatters/yapf.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TextDocument, 3 | FormattingOptions, 4 | CancellationToken, 5 | Range, 6 | Thenable, 7 | TextEdit, 8 | OutputChannel, 9 | } from 'coc.nvim'; 10 | import type { IPythonSettings } from '../../types'; 11 | import { BaseFormatter } from './baseFormatter'; 12 | 13 | export class YapfFormatter extends BaseFormatter { 14 | constructor( 15 | public readonly pythonSettings: IPythonSettings, 16 | public readonly outputChannel: OutputChannel, 17 | ) { 18 | super('yapf', pythonSettings, outputChannel); 19 | } 20 | 21 | public formatDocument( 22 | document: TextDocument, 23 | options: FormattingOptions, 24 | token: CancellationToken, 25 | range?: Range, 26 | ): Thenable { 27 | const yapfArgs = ['--diff']; 28 | if (this.pythonSettings.formatting.yapfArgs.length > 0) { 29 | yapfArgs.push(...this.pythonSettings.formatting.yapfArgs); 30 | } 31 | if (range) { 32 | yapfArgs.push('--lines', `${range.start.line + 1}-${range.end.line + 1}`); 33 | } 34 | // Yapf starts looking for config file starting from the file path. 35 | const fallbarFolder = this.getWorkspaceUri(document)?.fsPath; 36 | const cwd = this.getDocumentPath(document, fallbarFolder); 37 | const promise = super.provideDocumentFormattingEdits(document, options, token, yapfArgs, cwd); 38 | return promise; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/async.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | //====================== 4 | // Deferred 5 | 6 | export interface Deferred { 7 | readonly promise: Promise; 8 | readonly resolved: boolean; 9 | readonly rejected: boolean; 10 | readonly completed: boolean; 11 | resolve(value?: T | PromiseLike): void; 12 | reject(reason?: any): void; 13 | } 14 | 15 | class DeferredImpl implements Deferred { 16 | private _resolve!: (value?: T | PromiseLike) => void; 17 | private _reject!: (reason?: any) => void; 18 | private _resolved = false; 19 | private _rejected = false; 20 | private _promise: Promise; 21 | constructor(private scope: any = null) { 22 | this._promise = new Promise((res, rej) => { 23 | // @ts-expect-error 24 | this._resolve = res; 25 | this._reject = rej; 26 | }); 27 | } 28 | public resolve(_value?: T | PromiseLike) { 29 | this._resolve.apply(this.scope ? this.scope : this, [_value]); 30 | this._resolved = true; 31 | } 32 | public reject(_reason?: any) { 33 | this._reject.apply(this.scope ? this.scope : this, [_reason]); 34 | this._rejected = true; 35 | } 36 | get promise(): Promise { 37 | return this._promise; 38 | } 39 | get resolved(): boolean { 40 | return this._resolved; 41 | } 42 | get rejected(): boolean { 43 | return this._rejected; 44 | } 45 | get completed(): boolean { 46 | return this._rejected || this._resolved; 47 | } 48 | } 49 | export function createDeferred(scope: any = null): Deferred { 50 | return new DeferredImpl(scope); 51 | } 52 | -------------------------------------------------------------------------------- /src/features/importCompletion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | type CompletionContext, 4 | type CompletionItem, 5 | CompletionItemKind, 6 | type CompletionItemProvider, 7 | type LinesTextDocument, 8 | type Position, 9 | Range, 10 | sources, 11 | } from 'coc.nvim'; 12 | 13 | export class ImportCompletionProvider implements CompletionItemProvider { 14 | async provideCompletionItems( 15 | document: LinesTextDocument, 16 | position: Position, 17 | token: CancellationToken, 18 | context: CompletionContext, 19 | ): Promise { 20 | if (context.triggerCharacter !== ' ') return []; 21 | const line = document.getText(Range.create(position.line, 0, position.line, position.character)).trim(); 22 | if (!line.includes('from') && !line.includes('import')) return []; 23 | 24 | const parts = line.split(' '); 25 | const first = parts[0]; 26 | const last = parts[parts.length - 1]; 27 | if (first !== last && first === 'from' && last !== 'import' && !last.endsWith(',')) { 28 | return [{ label: 'import' }]; 29 | } 30 | const source = sources.sources.find((s) => s.name.includes('pyright')); 31 | if (!source) return []; 32 | const result = await source.doComplete(context.option, token); 33 | if (!result) return []; 34 | const items: CompletionItem[] = []; 35 | for (const o of result.items) { 36 | items.push({ 37 | // @ts-expect-error 38 | label: o.label || o.word, 39 | sortText: o.sortText, 40 | kind: CompletionItemKind.Module, 41 | filterText: o.filterText, 42 | }); 43 | } 44 | return items; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 17 * * 6' 11 | 12 | jobs: 13 | CodeQL-Build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # Initializes the CodeQL tools for scanning. 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v3 28 | # Override language selection by uncommenting this and choosing your languages 29 | # with: 30 | # languages: go, javascript, csharp, python, cpp, java 31 | 32 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 33 | # If this step fails, then you should remove it and run the build manually (see below) 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v3 36 | 37 | # ℹ️ Command-line programs to run using the OS shell. 38 | # 📚 https://git.io/JvXDl 39 | 40 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 41 | # and modify them (or add more) to build your code if your project 42 | # uses a compiled language 43 | 44 | #- run: | 45 | # make bootstrap 46 | # make release 47 | 48 | - name: Perform CodeQL Analysis 49 | uses: github/codeql-action/analyze@v3 50 | -------------------------------------------------------------------------------- /src/features/linters/prospector.ts: -------------------------------------------------------------------------------- 1 | import { type CancellationToken, type TextDocument, Uri, workspace } from 'coc.nvim'; 2 | import path from 'node:path'; 3 | import type { ILintMessage } from '../../types'; 4 | import { BaseLinter } from './baseLinter'; 5 | 6 | interface IProspectorResponse { 7 | messages: IProspectorMessage[]; 8 | } 9 | interface IProspectorMessage { 10 | source: string; 11 | message: string; 12 | code: string; 13 | location: IProspectorLocation; 14 | } 15 | interface IProspectorLocation { 16 | function: string; 17 | path: string; 18 | line: number; 19 | character: number; 20 | module: 'beforeFormat'; 21 | } 22 | 23 | export class Prospector extends BaseLinter { 24 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 25 | const relativePath = path.relative(workspace.root, Uri.parse(document.uri).fsPath); 26 | return this.run(['--absolute-paths', '--output-format=json', relativePath], document, cancellation); 27 | } 28 | protected async parseMessages(output: string, _document: TextDocument, _regEx: string) { 29 | let parsedData: IProspectorResponse; 30 | try { 31 | parsedData = JSON.parse(output); 32 | } catch (_ex) { 33 | this.outputChannel.appendLine(`${'#'.repeat(10)}Linting Output - ${this.info.id}${'#'.repeat(10)}`); 34 | this.outputChannel.append(output); 35 | return []; 36 | } 37 | return parsedData.messages 38 | .filter((_value, index) => index <= this.pythonSettings.linting.maxNumberOfProblems) 39 | .map((msg) => { 40 | const lineNumber = msg.location.line === null || Number.isNaN(msg.location.line) ? 1 : msg.location.line; 41 | 42 | return { 43 | code: msg.code, 44 | message: msg.message, 45 | column: msg.location.character, 46 | line: lineNumber, 47 | type: msg.code, 48 | provider: `${this.info.id} - ${msg.source}`, 49 | }; 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/features/linters/linterInfo.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { type Uri, workspace } from 'coc.nvim'; 5 | import * as path from 'node:path'; 6 | import which from 'which'; 7 | import type { PythonSettings } from '../../configSettings'; 8 | import type { ExecutionInfo, ILinterInfo, LinterId, Product } from '../../types'; 9 | 10 | export class LinterInfo implements ILinterInfo { 11 | private _id: LinterId; 12 | private _product: Product; 13 | // biome-ignore lint/correctness/noUnusedPrivateClassMembers: x 14 | private _configFileNames: string[]; 15 | 16 | constructor( 17 | product: Product, 18 | id: LinterId, 19 | protected configService: PythonSettings, 20 | configFileNames: string[] = [], 21 | ) { 22 | this._product = product; 23 | this._id = id; 24 | this._configFileNames = configFileNames; 25 | } 26 | 27 | public get id(): LinterId { 28 | return this._id; 29 | } 30 | public get product(): Product { 31 | return this._product; 32 | } 33 | public get stdinSupport(): boolean { 34 | const settings = this.configService; 35 | return (settings.linting as any)[`${this.id}Stdin`] as boolean; 36 | } 37 | 38 | public isEnabled(_resource?: Uri): boolean { 39 | const settings = this.configService; 40 | return (settings.linting as any)[`${this.id}Enabled`] as boolean; 41 | } 42 | 43 | public pathName(_resource?: Uri): string { 44 | const settings = this.configService; 45 | return (settings.linting as any)[`${this.id}Path`] as string; 46 | } 47 | public linterArgs(_resource?: Uri): string[] { 48 | const settings = this.configService; 49 | const args = (settings.linting as any)[`${this.id}Args`]; 50 | return Array.isArray(args) ? (args as string[]) : []; 51 | } 52 | public getExecutionInfo(customArgs: string[], resource?: Uri): ExecutionInfo { 53 | const cmd = workspace.expand(this.pathName(resource)); 54 | const execPath = which.sync(cmd, { nothrow: true }) || this.pathName(resource); 55 | const args = this.linterArgs(resource).concat(customArgs); 56 | let moduleName: string | undefined; 57 | 58 | // If path information is not available, then treat it as a module, 59 | if (path.basename(execPath) === execPath) { 60 | moduleName = execPath; 61 | } 62 | 63 | return { execPath, moduleName, args, product: this.product }; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/parsers/testFramework.ts: -------------------------------------------------------------------------------- 1 | import { printParseNodeType } from '@zzzen/pyright-internal/dist/analyzer/parseTreeUtils'; 2 | import { ParseTreeWalker } from '@zzzen/pyright-internal/dist/analyzer/parseTreeWalker'; 3 | import type { ClassNode, FunctionNode, ParseNode, SuiteNode } from '@zzzen/pyright-internal/dist/parser/parseNodes'; 4 | import type { TestingFramework } from '../types'; 5 | 6 | export type FunctionFormatItemType = { 7 | value: string; 8 | startOffset: number; 9 | endOffset: number; 10 | }; 11 | 12 | export class TestFrameworkWalker extends ParseTreeWalker { 13 | public featureItems: FunctionFormatItemType[] = []; 14 | private testFramework: TestingFramework; 15 | 16 | constructor(testFramework: TestingFramework) { 17 | super(); 18 | this.testFramework = testFramework; 19 | } 20 | 21 | override visitFunction(node: FunctionNode): boolean { 22 | if (node.d.name.d.value.startsWith('test_')) { 23 | if (node.parent && printParseNodeType(node.parent.nodeType) === 'Suite') { 24 | let fullyQualifiedTestName = ''; 25 | let currentNode: FunctionNode | ParseNode | undefined = node; 26 | let parentSuiteNode = currentNode?.parent as SuiteNode; 27 | while (parentSuiteNode.parent && printParseNodeType(parentSuiteNode.parent.nodeType) === 'Class') { 28 | const classNode = parentSuiteNode.parent as ClassNode; 29 | 30 | let combineString: string | undefined; 31 | if (this.testFramework === 'unittest') { 32 | combineString = '.'; 33 | } else if (this.testFramework === 'pytest') { 34 | combineString = '::'; 35 | } 36 | fullyQualifiedTestName = classNode.d.name.d.value + combineString + fullyQualifiedTestName; 37 | currentNode = currentNode?.parent?.parent; 38 | parentSuiteNode = currentNode?.parent as SuiteNode; 39 | } 40 | this.featureItems.push({ 41 | value: fullyQualifiedTestName + node.d.name.d.value, 42 | startOffset: node.start, 43 | endOffset: node.start + node.length - 1, 44 | }); 45 | } else { 46 | if (this.testFramework === 'pytest') { 47 | this.featureItems.push({ 48 | value: node.d.name.d.value, 49 | startOffset: node.start, 50 | endOffset: node.start + node.length - 1, 51 | }); 52 | } 53 | } 54 | } 55 | 56 | return super.visitFunction(node); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/features/lintting.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commands, 3 | type ConfigurationChangeEvent, 4 | type DiagnosticCollection, 5 | type DidChangeTextDocumentParams, 6 | type Disposable, 7 | type ExtensionContext, 8 | type TextDocument, 9 | Uri, 10 | workspace, 11 | } from 'coc.nvim'; 12 | import { PythonSettings } from '../configSettings'; 13 | import { LintingEngine } from './linters/lintingEngine'; 14 | 15 | export class LinterProvider implements Disposable { 16 | private context: ExtensionContext; 17 | private disposables: Disposable[]; 18 | private pythonSettings: PythonSettings; 19 | private engine: LintingEngine; 20 | 21 | public constructor(context: ExtensionContext) { 22 | this.context = context; 23 | this.disposables = []; 24 | 25 | this.engine = new LintingEngine(); 26 | this.pythonSettings = PythonSettings.getInstance(); 27 | 28 | workspace.onDidOpenTextDocument((e) => this.onDocumentOpened(e), this.context.subscriptions); 29 | workspace.onDidCloseTextDocument((e) => this.onDocumentClosed(e), this.context.subscriptions); 30 | workspace.onDidSaveTextDocument((e) => this.onDocumentSaved(e), this.context.subscriptions); 31 | workspace.onDidChangeTextDocument((e) => this.onDocumentChanged(e), this.context.subscriptions); 32 | 33 | const disposable = workspace.onDidChangeConfiguration(this.lintSettingsChangedHandler.bind(this)); 34 | this.disposables.push(disposable); 35 | 36 | this.disposables.push(commands.registerCommand('python.runLinting', this.runLinting.bind(this))); 37 | 38 | setTimeout(() => this.engine.lintOpenPythonFiles().catch(this.emptyFn), 1200); 39 | } 40 | 41 | public dispose() { 42 | for (const d of this.disposables) { 43 | d.dispose(); 44 | } 45 | } 46 | 47 | private runLinting(): Promise { 48 | return this.engine.lintOpenPythonFiles(); 49 | } 50 | 51 | private lintSettingsChangedHandler(e: ConfigurationChangeEvent) { 52 | // Look for python files that belong to the specified workspace folder. 53 | for (const document of workspace.textDocuments) { 54 | if (e.affectsConfiguration('python.linting', document.uri)) { 55 | this.engine.lintDocument(document).catch(() => {}); 56 | } 57 | } 58 | } 59 | 60 | private onDocumentOpened(document: TextDocument): void { 61 | this.engine.lintDocument(document).catch(() => {}); 62 | } 63 | 64 | private onDocumentSaved(document: TextDocument): void { 65 | if (this.pythonSettings.linting.lintOnSave) { 66 | this.engine.lintDocument(document).catch(() => {}); 67 | } 68 | } 69 | 70 | private onDocumentChanged(e: DidChangeTextDocumentParams) { 71 | const document = workspace.getDocument(e.textDocument.uri); 72 | if (!document) { 73 | return; 74 | } 75 | this.engine.lintDocument(document.textDocument, true).catch(() => {}); 76 | } 77 | 78 | private onDocumentClosed(document: TextDocument) { 79 | if (!document || !Uri.parse(document.uri).fsPath || !document.uri) { 80 | return; 81 | } 82 | 83 | this.engine.clearDiagnostics(document); 84 | } 85 | 86 | private emptyFn(): void { 87 | // noop 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/parsers/inlayHints.ts: -------------------------------------------------------------------------------- 1 | import { getCallNodeAndActiveParamIndex } from '@zzzen/pyright-internal/dist/analyzer/parseTreeUtils'; 2 | import { ParseTreeWalker } from '@zzzen/pyright-internal/dist/analyzer/parseTreeWalker'; 3 | import { 4 | type ArgumentNode, 5 | type AssignmentNode, 6 | type FunctionNode, 7 | type MemberAccessNode, 8 | type NameNode, 9 | type ParseNode, 10 | ParseNodeType, 11 | } from '@zzzen/pyright-internal/dist/parser/parseNodes'; 12 | import type { ParseFileResults } from '@zzzen/pyright-internal/dist/parser/parser'; 13 | 14 | type InlayHintsItem = { 15 | hintType: 'variable' | 'functionReturn' | 'parameter'; 16 | startOffset: number; 17 | endOffset: number; 18 | value?: string; 19 | }; 20 | 21 | function isLeftSideOfAssignment(node: ParseNode): boolean { 22 | if (node.parent?.nodeType !== ParseNodeType.Assignment) { 23 | return false; 24 | } 25 | return node.start < (node.parent as AssignmentNode).d.rightExpr.start; 26 | } 27 | 28 | export class TypeInlayHintsWalker extends ParseTreeWalker { 29 | public featureItems: InlayHintsItem[] = []; 30 | 31 | constructor(private readonly _parseResults: ParseFileResults) { 32 | super(); 33 | } 34 | 35 | override visitName(node: NameNode): boolean { 36 | if (isLeftSideOfAssignment(node)) { 37 | this.featureItems.push({ 38 | hintType: 'variable', 39 | startOffset: node.start, 40 | endOffset: node.start + node.length - 1, 41 | value: node.d.value, 42 | }); 43 | } 44 | return super.visitName(node); 45 | } 46 | 47 | override visitMemberAccess(node: MemberAccessNode): boolean { 48 | if (isLeftSideOfAssignment(node)) { 49 | this.featureItems.push({ 50 | hintType: 'variable', 51 | startOffset: node.d.member.start, 52 | endOffset: node.d.member.start + node.d.member.length - 1, 53 | value: node.d.member.d.value, 54 | }); 55 | } 56 | return super.visitMemberAccess(node); 57 | } 58 | 59 | override visitArgument(node: ArgumentNode): boolean { 60 | if (node.parent) { 61 | if (node.parent.nodeType === ParseNodeType.Assignment) { 62 | return false; 63 | } 64 | const result = getCallNodeAndActiveParamIndex(node, node.start, this._parseResults.tokenizerOutput.tokens); 65 | if (!result?.callNode || result.callNode.d.args[result.activeIndex].d.name) { 66 | return false; 67 | } 68 | this.featureItems.push({ 69 | hintType: 'parameter', 70 | startOffset: node.start, 71 | endOffset: node.start + node.length - 1, 72 | }); 73 | } 74 | return super.visitArgument(node); 75 | } 76 | 77 | override visitFunction(node: FunctionNode): boolean { 78 | // If the code describes a type, do not add the item. 79 | // Add item only if "node.returnTypeAnnotation" does not exist. 80 | if (!node.d.returnAnnotation) { 81 | this.featureItems.push({ 82 | hintType: 'functionReturn', 83 | startOffset: node.d.name.start, 84 | endOffset: node.d.suite.start, 85 | value: node.d.name.d.value, 86 | }); 87 | } 88 | return super.visitFunction(node); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/features/semanticTokens.ts: -------------------------------------------------------------------------------- 1 | import { convertOffsetsToRange, convertTextRangeToRange } from '@zzzen/pyright-internal/dist/common/positionUtils'; 2 | import { 3 | type CancellationToken, 4 | type DocumentSemanticTokensProvider, 5 | type LinesTextDocument, 6 | type ProviderResult, 7 | SemanticTokenModifiers, 8 | SemanticTokenTypes, 9 | type SemanticTokens, 10 | SemanticTokensBuilder, 11 | type SemanticTokensLegend, 12 | } from 'coc.nvim'; 13 | import * as parser from '../parsers'; 14 | import { SemanticTokensWalker } from '../parsers'; 15 | 16 | const tokenTypes: string[] = [ 17 | SemanticTokenTypes.class, 18 | SemanticTokenTypes.decorator, 19 | SemanticTokenTypes.enum, 20 | SemanticTokenTypes.enumMember, 21 | SemanticTokenTypes.function, 22 | SemanticTokenTypes.keyword, 23 | SemanticTokenTypes.method, 24 | SemanticTokenTypes.namespace, 25 | SemanticTokenTypes.parameter, 26 | SemanticTokenTypes.property, 27 | SemanticTokenTypes.typeParameter, 28 | SemanticTokenTypes.variable, 29 | ]; 30 | 31 | const tokenModifiers: string[] = [ 32 | SemanticTokenModifiers.definition, 33 | SemanticTokenModifiers.declaration, 34 | SemanticTokenModifiers.async, 35 | ]; 36 | 37 | function encodeTokenType(type: string): number { 38 | const idx = tokenTypes.indexOf(type); 39 | if (idx === -1) { 40 | throw new Error(`Unknown token type: ${type}`); 41 | } 42 | return idx; 43 | } 44 | 45 | function encodeTokenModifiers(modifiers: string[]): number { 46 | let data = 0; 47 | for (const t of modifiers) { 48 | const idx = tokenModifiers.indexOf(t); 49 | if (idx === undefined) { 50 | continue; 51 | } 52 | data |= 1 << idx; 53 | } 54 | return data; 55 | } 56 | 57 | export class PythonSemanticTokensProvider implements DocumentSemanticTokensProvider { 58 | public readonly legend: SemanticTokensLegend = { tokenTypes, tokenModifiers }; 59 | 60 | public provideDocumentSemanticTokens( 61 | document: LinesTextDocument, 62 | token: CancellationToken, 63 | ): ProviderResult { 64 | const parsed = parser.parse(document.getText()); 65 | if (!parsed) return null; 66 | if (token?.isCancellationRequested) return null; 67 | 68 | const builder = new SemanticTokensBuilder(this.legend); 69 | // @ts-expect-error 70 | for (const item of parsed.tokenizerOutput.tokens._items) { 71 | if (item.type === 8 && item.keywordType) { 72 | const range = convertTextRangeToRange(item, parsed.tokenizerOutput.lines); 73 | builder.push(range.start.line, range.start.character, item.length, encodeTokenType(SemanticTokenTypes.keyword)); 74 | } 75 | } 76 | 77 | const walker = new SemanticTokensWalker(); 78 | walker.walk(parsed.parserOutput.parseTree); 79 | 80 | for (const item of walker.semanticItems) { 81 | const range = convertOffsetsToRange(item.start, item.start + item.length, parsed.tokenizerOutput.lines); 82 | builder.push( 83 | range.start.line, 84 | range.start.character, 85 | item.length, 86 | encodeTokenType(item.type), 87 | encodeTokenModifiers(item.modifiers), 88 | ); 89 | } 90 | 91 | return builder.build(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/features/sortImports.ts: -------------------------------------------------------------------------------- 1 | import { type OutputChannel, type TextDocument, commands, window, workspace } from 'coc.nvim'; 2 | import fs from 'node:fs'; 3 | import which from 'which'; 4 | import { PythonSettings } from '../configSettings'; 5 | import { PythonExecutionService } from '../processService'; 6 | import type { ExecutionInfo } from '../types'; 7 | import { getTempFileWithDocumentContents, getTextEditsFromPatch } from '../utils'; 8 | 9 | type SortProvider = 'pyright' | 'isort' | 'ruff'; 10 | function getSortProviderInfo(provider: SortProvider): ExecutionInfo { 11 | const pythonSettings = PythonSettings.getInstance(); 12 | const modulePath = provider === 'isort' ? pythonSettings.sortImports.path : pythonSettings.linting.ruffPath; 13 | const execPath = which.sync(workspace.expand(modulePath), { nothrow: true }) || ''; 14 | let args: string[] = []; 15 | if (provider === 'isort') { 16 | args = ['--diff']; 17 | for (const item of pythonSettings.sortImports.args) { 18 | args.push(workspace.expand(item)); 19 | } 20 | } else if (provider === 'ruff') { 21 | args = ['check', '--diff'].concat(['--quiet', '--select', 'I001']); 22 | } 23 | 24 | return { execPath, args }; 25 | } 26 | 27 | async function generateImportsDiff( 28 | provider: SortProvider, 29 | document: TextDocument, 30 | outputChannel: OutputChannel, 31 | ): Promise { 32 | const tempFile = await getTempFileWithDocumentContents(document); 33 | 34 | const executionInfo = getSortProviderInfo(provider); 35 | executionInfo.args.push(tempFile); 36 | 37 | outputChannel.appendLine(`${'#'.repeat(10)} sortImports`); 38 | outputChannel.appendLine(`execPath: ${executionInfo.execPath}`); 39 | outputChannel.appendLine(`args: ${executionInfo.args.join(' ')} `); 40 | 41 | try { 42 | const pythonToolsExecutionService = new PythonExecutionService(); 43 | const result = await pythonToolsExecutionService.exec(executionInfo, { throwOnStdErr: true }); 44 | return result.stdout; 45 | } finally { 46 | await fs.promises.unlink(tempFile); 47 | } 48 | } 49 | 50 | export async function sortImports(outputChannel: OutputChannel): Promise { 51 | const doc = await workspace.document; 52 | if (!doc || doc.filetype !== 'python' || doc.lineCount <= 1) { 53 | return; 54 | } 55 | 56 | const provider = workspace.getConfiguration('pyright').get('organizeimports.provider', 'pyright'); 57 | if (provider === 'pyright') { 58 | await commands.executeCommand('pyright.organizeimports'); 59 | return; 60 | } 61 | 62 | try { 63 | const patch = await generateImportsDiff(provider, doc.textDocument, outputChannel); 64 | const edits = getTextEditsFromPatch(doc.getDocumentContent(), patch); 65 | await doc.applyEdits(edits); 66 | 67 | outputChannel.appendLine(`${'#'.repeat(10)} sortImports Output ${'#'.repeat(10)}`); 68 | outputChannel.appendLine(patch); 69 | } catch (err) { 70 | let message = ''; 71 | if (typeof err === 'string') { 72 | message = err; 73 | } else if (err instanceof Error) { 74 | message = err.message; 75 | } 76 | outputChannel.appendLine(`${'#'.repeat(10)} sortImports Error ${'#'.repeat(10)}`); 77 | outputChannel.appendLine(message); 78 | window.showErrorMessage('Failed to sort imports'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/features/formatters/blackd.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process'; 2 | import { 3 | type CancellationToken, 4 | fetch, 5 | type FormattingOptions, 6 | type OutputChannel, 7 | type Range, 8 | type TextDocument, 9 | type TextEdit, 10 | type Thenable, 11 | Uri, 12 | window, 13 | } from 'coc.nvim'; 14 | import getPort from 'get-port'; 15 | import { getTextEditsFromPatch } from '../../utils'; 16 | import type { IPythonSettings } from '../../types'; 17 | import { BaseFormatter } from './baseFormatter'; 18 | 19 | export class BlackdFormatter extends BaseFormatter { 20 | private blackdHTTPURL = ''; 21 | 22 | constructor( 23 | public readonly pythonSettings: IPythonSettings, 24 | public readonly outputChannel: OutputChannel, 25 | ) { 26 | super('blackd', pythonSettings, outputChannel); 27 | 28 | this.blackdHTTPURL = this.pythonSettings.formatting.blackdHTTPURL; 29 | if (!this.blackdHTTPURL.length) { 30 | this.launchServer(); 31 | } 32 | } 33 | 34 | private async launchServer(): Promise { 35 | const port = await getPort({ port: 45484 }); 36 | this.blackdHTTPURL = `http://127.0.0.1:${port}`; 37 | 38 | const blackdPath = this.pythonSettings.formatting.blackdPath; 39 | spawn(blackdPath, ['--bind-port', String(port)]).on('error', (e) => { 40 | this.outputChannel.appendLine(''); 41 | this.outputChannel.appendLine('spawn blackd HTTP server error'); 42 | this.outputChannel.appendLine(e.message); 43 | this.outputChannel.appendLine('make sure you have installed blackd by `pip install "black[d]"`'); 44 | this.blackdHTTPURL = ''; 45 | }); 46 | } 47 | 48 | private async handle(document: TextDocument): Promise { 49 | if (!this.blackdHTTPURL.length) { 50 | return Promise.resolve([]); 51 | } 52 | 53 | try { 54 | const headers = Object.assign({ 'X-Diff': 1 }, this.pythonSettings.formatting.blackdHTTPHeaders); 55 | const patch = await fetch(this.blackdHTTPURL, { method: 'POST', data: document.getText(), headers }); 56 | 57 | this.outputChannel.appendLine(''); 58 | this.outputChannel.appendLine(`${'#'.repeat(10)} ${this.Id} output:`); 59 | this.outputChannel.appendLine(patch.toString()); 60 | 61 | return getTextEditsFromPatch(document.getText(), patch.toString()); 62 | } catch (e) { 63 | window.showErrorMessage('blackd request error'); 64 | this.outputChannel.appendLine(''); 65 | this.outputChannel.appendLine(`${'#'.repeat(10)} blackd request error:`); 66 | if (typeof e === 'string') { 67 | this.outputChannel.appendLine(e); 68 | } else if (e instanceof Error) { 69 | this.outputChannel.appendLine(e.message); 70 | } 71 | return []; 72 | } 73 | } 74 | 75 | public formatDocument( 76 | document: TextDocument, 77 | _options: FormattingOptions, 78 | _token: CancellationToken, 79 | range?: Range, 80 | ): Thenable { 81 | if (range) { 82 | const msg = 'blackd does not support range formatting'; 83 | this.outputChannel.appendLine(msg); 84 | window.showErrorMessage(msg); 85 | return Promise.resolve([]); 86 | } 87 | if (this.pythonSettings.stdLibs.some((p) => Uri.parse(document.uri).fsPath.startsWith(p))) { 88 | return Promise.resolve([]); 89 | } 90 | 91 | return this.handle(document); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/features/linters/pydocstyle.ts: -------------------------------------------------------------------------------- 1 | import { Uri, workspace, type CancellationToken, type TextDocument } from 'coc.nvim'; 2 | import * as path from 'node:path'; 3 | import { LintMessageSeverity, type ILintMessage } from '../../types'; 4 | import { BaseLinter } from './baseLinter'; 5 | 6 | export class PyDocStyle extends BaseLinter { 7 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 8 | const baseFileName = path.basename(Uri.parse(document.uri).fsPath); 9 | if (/^test_.*\.py$/.test(baseFileName)) return []; 10 | const messages = await this.run([Uri.parse(document.uri).fsPath], document, cancellation); 11 | // All messages in pep8 are treated as warnings for now. 12 | for (const msg of messages) { 13 | msg.severity = LintMessageSeverity.Warning; 14 | } 15 | 16 | return messages; 17 | } 18 | 19 | protected async parseMessages(output: string, document: TextDocument) { 20 | let outputLines = output.split(/\r?\n/g); 21 | const baseFileName = path.basename(Uri.parse(document.uri).fsPath); 22 | 23 | // Remember, the first line of the response contains the file name and line number, the next line contains the error message. 24 | // So we have two lines per message, hence we need to take lines in pairs. 25 | const maxLines = this.pythonSettings.linting.maxNumberOfProblems * 2; 26 | // First line is almost always empty. 27 | const oldOutputLines = outputLines.filter((line) => line.length > 0); 28 | outputLines = []; 29 | for (let counter = 0; counter < oldOutputLines.length / 2; counter += 1) { 30 | outputLines.push(oldOutputLines[2 * counter] + oldOutputLines[2 * counter + 1]); 31 | } 32 | const doc = workspace.getDocument(document.uri); 33 | if (!doc) { 34 | return []; 35 | } 36 | 37 | return ( 38 | outputLines 39 | .filter((value, index) => index < maxLines && value.indexOf(':') >= 0) 40 | .map((line) => { 41 | // Windows will have a : after the drive letter (e.g. c:\). 42 | if (this.isWindows) { 43 | return line.substring(line.indexOf(`${baseFileName}:`) + baseFileName.length + 1).trim(); 44 | } 45 | return line.substring(line.indexOf(':') + 1).trim(); 46 | }) 47 | // Iterate through the lines (skipping the messages). 48 | // So, just iterate the response in pairs. 49 | .map((line) => { 50 | try { 51 | if (line.trim().length === 0) { 52 | return null; 53 | } 54 | const lineNumber = parseInt(line.substring(0, line.indexOf(' ')), 10); 55 | const part = line.substring(line.indexOf(':') + 1).trim(); 56 | const code = part.substring(0, part.indexOf(':')).trim(); 57 | const message = part.substring(part.indexOf(':') + 1).trim(); 58 | 59 | const sourceLine = doc.getline(lineNumber - 1); 60 | const trmmedSourceLine = sourceLine.trim(); 61 | const sourceStart = sourceLine.indexOf(trmmedSourceLine); 62 | 63 | return { 64 | code, 65 | message, 66 | column: sourceStart, 67 | line: lineNumber, 68 | type: '', 69 | provider: this.info.id, 70 | } as ILintMessage; 71 | } catch (err) { 72 | this.outputChannel.appendLine(`Failed to parse pydocstyle line '${line}'`); 73 | if (typeof err === 'string') { 74 | this.outputChannel.appendLine(err); 75 | } else if (err instanceof Error) { 76 | this.outputChannel.appendLine(err.message); 77 | } 78 | return null; 79 | } 80 | }) 81 | .filter((item) => item !== undefined) 82 | .map((item) => item!) 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/features/testing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CodeAction, 3 | CodeActionKind, 4 | type CodeActionProvider, 5 | type CodeLens, 6 | type CodeLensProvider, 7 | events, 8 | type LinesTextDocument, 9 | Position, 10 | Range, 11 | Uri, 12 | workspace, 13 | } from 'coc.nvim'; 14 | import path from 'node:path'; 15 | import * as parser from '../parsers'; 16 | import type { TestingFramework } from '../types'; 17 | import { rangeInRange } from '../utils'; 18 | 19 | export class TestFrameworkProvider implements CodeLensProvider, CodeActionProvider { 20 | private framework = workspace.getConfiguration('pyright').get('testing.provider', 'unittest'); 21 | 22 | private async parseDocument(document: LinesTextDocument): Promise { 23 | if (events.insertMode) return []; 24 | 25 | const fileName = path.basename(Uri.parse(document.uri).fsPath); 26 | if (document.languageId !== 'python' || (!fileName.startsWith('test_') && !fileName.endsWith('_test.py'))) { 27 | return []; 28 | } 29 | 30 | try { 31 | const parsed = parser.parse(document.getText()); 32 | if (!parsed) return []; 33 | 34 | const walker = new parser.TestFrameworkWalker(this.framework); 35 | walker.walk(parsed.parserOutput.parseTree); 36 | 37 | return walker.featureItems; 38 | } catch (_e) { 39 | return []; 40 | } 41 | } 42 | 43 | async provideCodeActions(document: LinesTextDocument, range: Range): Promise { 44 | if (range.start.line !== range.end.line || range.start.character !== range.end.character) return []; 45 | 46 | const featureItems = await this.parseDocument(document); 47 | if (!featureItems.length) return []; 48 | 49 | const actions: CodeAction[] = []; 50 | for (const item of featureItems) { 51 | if (item.startOffset && item.endOffset) { 52 | const itemStartPosition = document.positionAt(item.startOffset); 53 | const itemEndPosition = document.positionAt(item.endOffset); 54 | if (rangeInRange(range, Range.create(itemStartPosition, itemEndPosition))) { 55 | actions.push({ 56 | title: `RUN ${item.value} with ${this.framework}`, 57 | kind: CodeActionKind.Empty, 58 | command: { 59 | title: `RUN ${item.value} with ${this.framework}`, 60 | command: 'pyright.singleTest', 61 | }, 62 | }); 63 | } 64 | } 65 | } 66 | return actions; 67 | } 68 | 69 | async provideCodeLenses(document: LinesTextDocument): Promise { 70 | const featureItems = await this.parseDocument(document); 71 | if (!featureItems.length) return []; 72 | 73 | const codeLenses: CodeLens[] = []; 74 | for (const item of featureItems) { 75 | if (item.startOffset && item.endOffset) { 76 | const itemStartPosition = document.positionAt(item.startOffset); 77 | const itemEndPosition = document.positionAt(item.endOffset); 78 | 79 | const lens: CodeLens = { 80 | range: Range.create(itemStartPosition, itemEndPosition), 81 | command: { 82 | title: `>> [RUN ${this.framework}]`, 83 | command: 'pyright.singleTest', 84 | }, 85 | }; 86 | 87 | codeLenses.push(lens); 88 | } 89 | } 90 | 91 | // For some reason, the virtual text does not disappear even when the 92 | // number of code lens goes from 1 to 0. 93 | // 94 | // It may be a bug in coc.nvim itself, but it sends code lens with Range 95 | // of 0 and forces a refresh. 96 | if (codeLenses.length === 0) { 97 | codeLenses.push({ 98 | range: Range.create(Position.create(0, 0), Position.create(0, 0)), 99 | }); 100 | } 101 | 102 | return codeLenses; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/features/formatting.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | type Disposable, 4 | type DocumentFormattingEditProvider, 5 | type DocumentRangeFormattingEditProvider, 6 | type FormattingOptions, 7 | type OutputChannel, 8 | type ProviderResult, 9 | type Range, 10 | type TextDocument, 11 | type TextEdit, 12 | window, 13 | } from 'coc.nvim'; 14 | import { PythonSettings } from '../configSettings'; 15 | import type { FormatterId } from '../types'; 16 | import { AutoPep8Formatter } from './formatters/autopep8'; 17 | import type { BaseFormatter } from './formatters/baseFormatter'; 18 | import { BlackFormatter } from './formatters/black'; 19 | import { BlackdFormatter } from './formatters/blackd'; 20 | import { DarkerFormatter } from './formatters/darker'; 21 | import { PyinkFormatter } from './formatters/pyink'; 22 | import { RuffFormatter } from './formatters/ruff'; 23 | import { YapfFormatter } from './formatters/yapf'; 24 | 25 | export class PythonFormattingEditProvider 26 | implements DocumentFormattingEditProvider, DocumentRangeFormattingEditProvider 27 | { 28 | private formatters = new Map(); 29 | private disposables: Disposable[] = []; 30 | private pythonSettings: PythonSettings; 31 | private outputChannel: OutputChannel; 32 | 33 | constructor() { 34 | this.pythonSettings = PythonSettings.getInstance(); 35 | this.outputChannel = window.createOutputChannel('coc-pyright-formatting'); 36 | 37 | const provider = this.pythonSettings.formatting.provider; 38 | switch (provider) { 39 | case 'black': 40 | this.formatters.set('black', new BlackFormatter(this.pythonSettings, this.outputChannel)); 41 | break; 42 | case 'pyink': 43 | this.formatters.set('pyink', new PyinkFormatter(this.pythonSettings, this.outputChannel)); 44 | break; 45 | case 'blackd': 46 | this.formatters.set('blackd', new BlackdFormatter(this.pythonSettings, this.outputChannel)); 47 | break; 48 | case 'yapf': 49 | this.formatters.set('yapf', new YapfFormatter(this.pythonSettings, this.outputChannel)); 50 | break; 51 | case 'ruff': 52 | this.formatters.set('ruff', new RuffFormatter(this.pythonSettings, this.outputChannel)); 53 | break; 54 | case 'autopep8': 55 | this.formatters.set('autopep8', new AutoPep8Formatter(this.pythonSettings, this.outputChannel)); 56 | break; 57 | case 'darker': 58 | this.formatters.set('darker', new DarkerFormatter(this.pythonSettings, this.outputChannel)); 59 | break; 60 | default: 61 | break; 62 | } 63 | } 64 | 65 | private async _provideEdits( 66 | document: TextDocument, 67 | options: FormattingOptions, 68 | token: CancellationToken, 69 | range?: Range, 70 | ): Promise { 71 | const provider = this.pythonSettings.formatting.provider; 72 | const formatter = this.formatters.get(provider); 73 | if (!formatter) { 74 | this.outputChannel.appendLine( 75 | `${'#'.repeat(10)} Error: python.formatting.provider is ${provider}, which is not supported`, 76 | ); 77 | return []; 78 | } 79 | 80 | this.outputChannel.appendLine(`Using python from ${this.pythonSettings.pythonPath}\n`); 81 | this.outputChannel.appendLine(`${'#'.repeat(10)} active formattor: ${formatter.Id}`); 82 | return formatter.formatDocument(document, options, token, range); 83 | } 84 | 85 | provideDocumentFormattingEdits( 86 | document: TextDocument, 87 | options: FormattingOptions, 88 | token: CancellationToken, 89 | ): ProviderResult { 90 | return this._provideEdits(document, options, token); 91 | } 92 | 93 | provideDocumentRangeFormattingEdits( 94 | document: TextDocument, 95 | range: Range, 96 | options: FormattingOptions, 97 | token: CancellationToken, 98 | ): ProviderResult { 99 | return this._provideEdits(document, options, token, range); 100 | } 101 | 102 | public dispose() { 103 | for (const d of this.disposables) { 104 | d.dispose(); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/features/linters/pytype.ts: -------------------------------------------------------------------------------- 1 | import { Uri, workspace, type CancellationToken, type TextDocument } from 'coc.nvim'; 2 | import fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | import { LintMessageSeverity, type ILintMessage } from '../../types'; 5 | import { BaseLinter } from './baseLinter'; 6 | 7 | const pytypecfg = 'pytype.cfg'; 8 | const REGEX = '^File \\"(?.*)\\", line (?\\d+), in (|\\w+): (?.*)\\r?(\\n|$)'; 9 | const pytypeErrors = [ 10 | // https://google.github.io/pytype/errors.html#error-classes 11 | 'annotation-type-mismatch', 12 | 'attribute-error', 13 | 'bad-concrete-type', 14 | 'bad-function-defaults', 15 | 'bad-return-type', 16 | 'bad-slots', 17 | 'bad-unpacking', 18 | 'base-class-error', 19 | 'container-type-mismatch', 20 | 'duplicate-keyword-argument', 21 | 'ignored-abstractmethod', 22 | 'ignored-metaclass', 23 | 'ignored-type-comment', 24 | 'import-error', 25 | 'invalid-annotation', 26 | 'invalid-directive', 27 | 'invalid-function-definition', 28 | 'invalid-function-type-comment', 29 | 'invalid-namedtuple-arg', 30 | 'invalid-super-call', 31 | 'invalid-typevar', 32 | 'key-error', 33 | 'late-directive', 34 | 'missing-parameter', 35 | 'module-attr', 36 | 'mro-error', 37 | 'name-error', 38 | 'not-callable', 39 | 'not-indexable', 40 | 'not-instantiable', 41 | 'not-supported-yet', 42 | 'not-writable', 43 | 'pyi-error', 44 | 'python-compiler-error', 45 | 'recursion-error', 46 | 'redundant-function-type-comment', 47 | 'reveal-type', 48 | 'unbound-type-param', 49 | 'unsupported-operands', 50 | 'wrong-arg-count', 51 | 'wrong-arg-types', 52 | 'wrong-keyword-args', 53 | ]; 54 | 55 | async function pathExists(p: string) { 56 | try { 57 | await fs.promises.access(p); 58 | return true; 59 | } catch { 60 | return false; 61 | } 62 | } 63 | 64 | export class Pytype extends BaseLinter { 65 | protected async runLinter(document: TextDocument, cancellation: CancellationToken): Promise { 66 | const args: string[] = []; 67 | if (await this.hasConfigurationFile(workspace.root)) { 68 | args.push('--config', pytypecfg); 69 | } 70 | args.push(Uri.parse(document.uri).fsPath); 71 | 72 | return await this.run(args, document, cancellation, REGEX); 73 | } 74 | 75 | protected async parseMessages(output: string, document: TextDocument, regEx: string): Promise { 76 | const outputLines = output.split(/\r?\n/g).filter((line) => line.startsWith('File')); 77 | const newOutput = outputLines.join('\n'); 78 | const messages = (await super.parseMessages(newOutput, document, regEx)).filter((msg) => { 79 | return msg.file && msg.file === Uri.parse(document.uri).fsPath; 80 | }); 81 | for (const msg of messages) { 82 | msg.type = 'Hint'; 83 | msg.severity = LintMessageSeverity.Hint; 84 | const match = /\[(.*)\]/g.exec(msg.message); 85 | if (match && match.length >= 2) { 86 | if (pytypeErrors.includes(match[1])) { 87 | msg.severity = LintMessageSeverity.Error; 88 | } 89 | } 90 | } 91 | 92 | return messages; 93 | } 94 | 95 | private async hasConfigurationFile(folder: string): Promise { 96 | if (await pathExists(path.join(folder, pytypecfg))) { 97 | return true; 98 | } 99 | 100 | let current = folder; 101 | let above = path.dirname(folder); 102 | do { 103 | if (!(await pathExists(path.join(current, '__init__.py')))) { 104 | break; 105 | } 106 | if (await pathExists(path.join(current, pytypecfg))) { 107 | return true; 108 | } 109 | current = above; 110 | above = path.dirname(above); 111 | } while (!this.arePathsSame(current, above)); 112 | 113 | return false; 114 | } 115 | 116 | private arePathsSame(p1: string, p2: string): boolean { 117 | const path1 = path.normalize(p1); 118 | const path2 = path.normalize(p2); 119 | if (this.isWindows) { 120 | return path1.toUpperCase() === path2.toUpperCase(); 121 | } 122 | return path1 === path2; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from 'node:child_process'; 2 | import { type Terminal, Uri, window, workspace } from 'coc.nvim'; 3 | import path from 'node:path'; 4 | import { PythonSettings } from './configSettings'; 5 | import * as parser from './parsers'; 6 | import type { TestingFramework } from './types'; 7 | 8 | let terminal: Terminal | undefined; 9 | 10 | const framework = workspace.getConfiguration('pyright').get('testing.provider', 'unittest'); 11 | 12 | function pythonSupportsPathFinder(pythonPath: string) { 13 | try { 14 | const pythonProcess = child_process.spawnSync( 15 | pythonPath, 16 | ['-c', 'from sys import version_info; exit(0) if (version_info[0] >= 3 and version_info[1] >= 4) else exit(1)'], 17 | { encoding: 'utf8' }, 18 | ); 19 | if (pythonProcess.error) return false; 20 | return pythonProcess.status === 0; 21 | } catch (_ex) { 22 | return false; 23 | } 24 | } 25 | 26 | function validPythonModule(pythonPath: string, moduleName: string) { 27 | const pythonArgs = pythonSupportsPathFinder(pythonPath) 28 | ? ['-c', `from importlib.machinery import PathFinder; assert PathFinder.find_spec("${moduleName}") is not None`] 29 | : ['-m', moduleName, '--help']; 30 | try { 31 | const pythonProcess = child_process.spawnSync(pythonPath, pythonArgs, { encoding: 'utf8' }); 32 | if (pythonProcess.error) return false; 33 | return pythonProcess.status === 0; 34 | } catch (_ex) { 35 | return false; 36 | } 37 | } 38 | 39 | async function runTest(uri: string, testFunction?: string) { 40 | const workspaceUri = Uri.parse(workspace.root).toString(); 41 | const relativeFileUri = uri.replace(`${workspaceUri}/`, ''); 42 | let testFile = ''; 43 | if (framework === 'pytest') { 44 | testFile = relativeFileUri.split('/').join(path.sep); 45 | } else { 46 | testFile = relativeFileUri.replace(/.py$/, '').split('/').join('.'); 47 | } 48 | 49 | const pythonPath = PythonSettings.getInstance().pythonPath; 50 | const exists = validPythonModule(pythonPath, framework); 51 | if (!exists) return window.showErrorMessage(`${framework} does not exist!`); 52 | 53 | if (terminal) { 54 | if (terminal.bufnr) { 55 | await workspace.nvim.command(`bd! ${terminal.bufnr}`); 56 | } 57 | terminal.dispose(); 58 | terminal = undefined; 59 | } 60 | 61 | terminal = await window.createTerminal({ name: framework, cwd: workspace.root }); 62 | const args: string[] = []; 63 | 64 | const testArgs = workspace.getConfiguration('pyright').get(`testing.${framework}Args`, []); 65 | if (testArgs) { 66 | if (Array.isArray(testArgs)) { 67 | args.push(...testArgs); 68 | } 69 | } 70 | 71 | // MEMO: pytest is string concatenation with '::' 72 | // MEMO: unittest is string concatenation with '.' 73 | const sep = framework === 'pytest' ? '::' : '.'; 74 | args.push(testFunction ? testFile + sep + testFunction : testFile); 75 | 76 | terminal.sendText(`${pythonPath} -m ${framework} ${args.join(' ')}`); 77 | } 78 | 79 | export async function runFileTest() { 80 | const { document } = await workspace.getCurrentState(); 81 | 82 | const fileName = path.basename(Uri.parse(document.uri).fsPath); 83 | if (document.languageId !== 'python' || (!fileName.startsWith('test_') && !fileName.endsWith('_test.py'))) { 84 | return window.showErrorMessage('This file is not a python test file!'); 85 | } 86 | 87 | runTest(document.uri); 88 | } 89 | 90 | export async function runSingleTest() { 91 | const { document, position } = await workspace.getCurrentState(); 92 | const fileName = path.basename(Uri.parse(document.uri).fsPath); 93 | if (document.languageId !== 'python' || (!fileName.startsWith('test_') && !fileName.endsWith('_test.py'))) { 94 | return window.showErrorMessage('This file is not a python test file!'); 95 | } 96 | 97 | const parsed = parser.parse(document.getText()); 98 | if (!parsed) return window.showErrorMessage('Test not found'); 99 | 100 | const walker = new parser.TestFrameworkWalker(framework); 101 | walker.walk(parsed.parserOutput.parseTree); 102 | 103 | let testFunction: string | undefined; 104 | for (const item of walker.featureItems) { 105 | const itemStartPosition = document.positionAt(item.startOffset); 106 | const itemEndPosition = document.positionAt(item.endOffset); 107 | if (position.line >= itemStartPosition.line && position.line <= itemEndPosition.line) { 108 | testFunction = item.value; 109 | } 110 | } 111 | 112 | if (!testFunction) return window.showErrorMessage('Test not found'); 113 | 114 | runTest(document.uri, testFunction); 115 | } 116 | -------------------------------------------------------------------------------- /src/features/linters/ruff.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | DiagnosticTag, 4 | type OutputChannel, 5 | Range, 6 | type TextDocument, 7 | TextEdit, 8 | Uri, 9 | type WorkspaceEdit, 10 | } from 'coc.nvim'; 11 | import { type ILinterInfo, type ILintMessage, LintMessageSeverity } from '../../types'; 12 | import { BaseLinter } from './baseLinter'; 13 | 14 | const COLUMN_OFF_SET = 1; 15 | 16 | interface IRuffLocation { 17 | row: number; 18 | column: number; 19 | } 20 | 21 | interface IRuffEdit { 22 | content: string; 23 | location: IRuffLocation; 24 | end_location: IRuffLocation; 25 | } 26 | 27 | interface IRuffFix { 28 | message: string; 29 | 30 | // ruff 0.0.260 or later 31 | edits?: IRuffEdit[]; 32 | 33 | // before 0.0.260 34 | content?: string; 35 | location?: IRuffLocation; 36 | end_location?: IRuffLocation; 37 | } 38 | 39 | // fix format 40 | // 41 | // old 42 | // { 43 | // "message": "Remove unused import: `sys`", 44 | // "content": "", 45 | // "location": {"row": 1, "column": 0}, 46 | // "end_location": {"row": 2, "column": 0} 47 | // } 48 | // 49 | // new, from ruff 0.0.260 50 | // { 51 | // "message": "Remove unused import: `sys`", 52 | // "edits": [ 53 | // { 54 | // "content": "", 55 | // "location": {"row": 1, "column": 0}, 56 | // "end_location": {"row": 2, "column": 0}, 57 | // } 58 | // ] 59 | // } 60 | 61 | interface IRuffLintMessage { 62 | kind: string | { [key: string]: any[] }; 63 | code: string; 64 | message: string; 65 | fix: IRuffFix; 66 | location: IRuffLocation; 67 | end_location: IRuffLocation; 68 | filename: string; 69 | noqa_row: number; 70 | url?: string; 71 | } 72 | 73 | export class Ruff extends BaseLinter { 74 | constructor(info: ILinterInfo, outputChannel: OutputChannel) { 75 | super(info, outputChannel, COLUMN_OFF_SET); 76 | } 77 | 78 | private fixToWorkspaceEdit(filename: string, fix: IRuffFix): { title: string; edit: WorkspaceEdit } | null { 79 | if (!fix) return null; 80 | 81 | const u = Uri.parse(filename).toString(); 82 | if (fix.edits?.length) { 83 | const changes = fix.edits.map((edit) => { 84 | const range = Range.create( 85 | edit.location.row - 1, 86 | edit.location.column - 1, 87 | edit.end_location.row - 1, 88 | edit.end_location.column - 1, 89 | ); 90 | return TextEdit.replace(range, edit.content); 91 | }); 92 | return { 93 | title: `Ruff: ${fix.message}`, 94 | edit: { 95 | changes: { 96 | [u]: changes, 97 | }, 98 | }, 99 | }; 100 | } 101 | if (fix.location && fix.end_location) { 102 | const range = Range.create( 103 | fix.location.row - 1, 104 | fix.location.column, 105 | fix.end_location.row - 1, 106 | fix.end_location.column, 107 | ); 108 | return { 109 | title: `Ruff: ${fix.message}`, 110 | edit: { 111 | changes: { 112 | [u]: [TextEdit.replace(range, fix.content || '')], 113 | }, 114 | }, 115 | }; 116 | } 117 | 118 | return null; 119 | } 120 | 121 | protected async parseMessages(output: string): Promise { 122 | try { 123 | const messages: ILintMessage[] = JSON.parse(output).map((msg: IRuffLintMessage) => { 124 | return { 125 | line: msg.location.row, 126 | column: msg.location.column - COLUMN_OFF_SET, 127 | endLine: msg.end_location.row, 128 | endColumn: msg.end_location.column, 129 | code: msg.code, 130 | message: msg.message, 131 | type: '', 132 | severity: LintMessageSeverity.Warning, // https://github.com/charliermarsh/ruff/issues/645 133 | tags: ['F401', 'F841'].includes(msg.code) ? [DiagnosticTag.Unnecessary] : [], 134 | provider: this.info.id, 135 | file: msg.filename, 136 | url: msg.url, 137 | fix: this.fixToWorkspaceEdit(msg.filename, msg.fix), 138 | } as ILintMessage; 139 | }); 140 | 141 | return messages; 142 | } catch (error) { 143 | this.outputChannel.appendLine(`Linting with ${this.info.id} failed:`); 144 | if (error instanceof Error) { 145 | this.outputChannel.appendLine(error.message.toString()); 146 | } 147 | return []; 148 | } 149 | } 150 | 151 | protected async runLinter(document: TextDocument, token: CancellationToken): Promise { 152 | const fsPath = Uri.parse(document.uri).fsPath; 153 | const args = ['check', '--output-format', 'json', '--exit-zero', '--stdin-filename', fsPath, '-']; 154 | return this.run(args, document, token); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/systemVariables.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as Path from 'node:path'; 7 | import type { IStringDictionary, ISystemVariables } from './types'; 8 | 9 | /** 10 | * @returns whether the provided parameter is a JavaScript Array or not. 11 | */ 12 | function isArray(array: any): array is any[] { 13 | if (Array.isArray) { 14 | return Array.isArray(array); 15 | } 16 | 17 | if (array && typeof array.length === 'number' && array.constructor === Array) { 18 | return true; 19 | } 20 | 21 | return false; 22 | } 23 | 24 | /** 25 | * @returns whether the provided parameter is a JavaScript String or not. 26 | */ 27 | function isString(str: any): str is string { 28 | if (typeof str === 'string' || str instanceof String) { 29 | return true; 30 | } 31 | 32 | return false; 33 | } 34 | 35 | /** 36 | * 37 | * @returns whether the provided parameter is of type `object` but **not** 38 | * `null`, an `array`, a `regexp`, nor a `date`. 39 | */ 40 | function isObject(obj: any): obj is any { 41 | return ( 42 | typeof obj === 'object' && obj !== null && !Array.isArray(obj) && !(obj instanceof RegExp) && !(obj instanceof Date) 43 | ); 44 | } 45 | 46 | abstract class AbstractSystemVariables implements ISystemVariables { 47 | public resolve(value: string): string; 48 | public resolve(value: string[]): string[]; 49 | public resolve(value: IStringDictionary): IStringDictionary; 50 | public resolve(value: IStringDictionary): IStringDictionary; 51 | public resolve(value: IStringDictionary>): IStringDictionary>; 52 | public resolve(value: any): any { 53 | if (isString(value)) { 54 | return this.__resolveString(value); 55 | } 56 | if (isArray(value)) { 57 | return this.__resolveArray(value); 58 | } 59 | if (isObject(value)) { 60 | return this.__resolveLiteral(value); 61 | } 62 | 63 | return value; 64 | } 65 | 66 | public resolveAny(value: T): T; 67 | public resolveAny(value: any): any { 68 | if (isString(value)) { 69 | return this.__resolveString(value); 70 | } 71 | if (isArray(value)) { 72 | return this.__resolveAnyArray(value); 73 | } 74 | if (isObject(value)) { 75 | return this.__resolveAnyLiteral(value); 76 | } 77 | 78 | return value; 79 | } 80 | 81 | private __resolveString(value: string): string { 82 | const regexp = /\$\{(.*?)\}/g; 83 | return value.replace(regexp, (match: string, name: string) => { 84 | const newValue = (this)[name]; 85 | if (isString(newValue)) { 86 | return newValue; 87 | } 88 | return match && (match.indexOf('env.') > 0 || match.indexOf('env:') > 0) ? '' : match; 89 | }); 90 | } 91 | 92 | private __resolveLiteral( 93 | values: IStringDictionary | string[]>, 94 | ): IStringDictionary | string[]> { 95 | const result: IStringDictionary | string[]> = Object.create(null); 96 | for (const key of Object.keys(values)) { 97 | const value = values[key]; 98 | result[key] = this.resolve(value); 99 | } 100 | return result; 101 | } 102 | 103 | private __resolveAnyLiteral(values: T): T; 104 | private __resolveAnyLiteral(values: any): any { 105 | const result: IStringDictionary | string[]> = Object.create(null); 106 | for (const key of Object.keys(values)) { 107 | const value = values[key]; 108 | result[key] = this.resolveAny(value); 109 | } 110 | return result; 111 | } 112 | 113 | private __resolveArray(value: string[]): string[] { 114 | return value.map((s) => this.__resolveString(s)); 115 | } 116 | 117 | private __resolveAnyArray(value: T[]): T[]; 118 | private __resolveAnyArray(value: any[]): any[] { 119 | return value.map((s) => this.resolveAny(s)); 120 | } 121 | } 122 | 123 | export class SystemVariables extends AbstractSystemVariables { 124 | private _workspaceFolder: string; 125 | private _workspaceFolderName: string; 126 | 127 | constructor(workspaceFolder?: string) { 128 | super(); 129 | this._workspaceFolder = typeof workspaceFolder === 'string' ? workspaceFolder : __dirname; 130 | this._workspaceFolderName = Path.basename(this._workspaceFolder); 131 | for (const key of Object.keys(process.env)) { 132 | (this as any as Record)[`env:${key}`] = ( 133 | this as any as Record 134 | )[`env.${key}`] = process.env[key]; 135 | } 136 | } 137 | 138 | public get cwd(): string { 139 | return this.workspaceFolder; 140 | } 141 | 142 | public get workspaceRoot(): string { 143 | return this._workspaceFolder; 144 | } 145 | 146 | public get workspaceFolder(): string { 147 | return this._workspaceFolder; 148 | } 149 | 150 | public get workspaceRootFolderName(): string { 151 | return this._workspaceFolderName; 152 | } 153 | 154 | public get workspaceFolderBasename(): string { 155 | return this._workspaceFolderName; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'node:child_process'; 2 | import type { CancellationToken, DiagnosticSeverity, DiagnosticTag, TextDocument, Uri } from 'coc.nvim'; 3 | import type { Observable } from 'rxjs'; 4 | 5 | export interface ExecutionInfo { 6 | execPath: string; 7 | moduleName?: string; 8 | args: string[]; 9 | product?: Product; 10 | } 11 | 12 | export interface ExecutionResult { 13 | stdout: T; 14 | stderr?: T; 15 | } 16 | 17 | export interface Output { 18 | source: 'stdout' | 'stderr'; 19 | out: T; 20 | } 21 | 22 | export interface ObservableExecutionResult { 23 | proc: ChildProcess | undefined; 24 | out: Observable>; 25 | dispose(): void; 26 | } 27 | 28 | export enum Product { 29 | pylint = 1, 30 | flake8 = 2, 31 | pycodestyle = 3, 32 | pylama = 4, 33 | prospector = 5, 34 | pydocstyle = 6, 35 | mypy = 7, 36 | bandit = 8, 37 | pytype = 9, 38 | yapf = 10, 39 | autopep8 = 11, 40 | black = 12, 41 | darker = 13, 42 | rope = 14, 43 | blackd = 15, 44 | pyflakes = 16, 45 | ruff = 17, 46 | pyink = 18, 47 | } 48 | 49 | export type LinterId = 50 | | 'bandit' 51 | | 'flake8' 52 | | 'mypy' 53 | | 'ruff' 54 | | 'pycodestyle' 55 | | 'prospector' 56 | | 'pydocstyle' 57 | | 'pyflakes' 58 | | 'pylama' 59 | | 'pylint' 60 | | 'pytype'; 61 | export type FormatterId = 'yapf' | 'black' | 'autopep8' | 'darker' | 'blackd' | 'pyink' | 'ruff'; 62 | export type TestingFramework = 'unittest' | 'pytest'; 63 | 64 | export interface ILinterInfo { 65 | readonly id: LinterId; 66 | readonly product: Product; 67 | readonly stdinSupport: boolean; 68 | isEnabled(resource?: Uri): boolean; 69 | pathName(resource?: Uri): string; 70 | linterArgs(resource?: Uri): string[]; 71 | getExecutionInfo(customArgs: string[], resource?: Uri): ExecutionInfo; 72 | } 73 | 74 | export enum LintMessageSeverity { 75 | Hint = 0, 76 | Error = 1, 77 | Warning = 2, 78 | Information = 3, 79 | } 80 | 81 | export interface ILintMessage { 82 | line: number; 83 | column: number; 84 | endLine?: number; 85 | endColumn?: number; 86 | code: string | undefined; 87 | message: string; 88 | type: string; 89 | severity?: LintMessageSeverity; 90 | tags?: DiagnosticTag[]; 91 | provider: string; 92 | file?: string; 93 | url?: string; 94 | fix?: any; 95 | } 96 | 97 | export interface ILinter { 98 | readonly info: ILinterInfo; 99 | lint(document: TextDocument, cancellation: CancellationToken): Promise; 100 | } 101 | 102 | interface PylintCategorySeverity { 103 | readonly convention: DiagnosticSeverity; 104 | readonly refactor: DiagnosticSeverity; 105 | readonly warning: DiagnosticSeverity; 106 | readonly error: DiagnosticSeverity; 107 | readonly fatal: DiagnosticSeverity; 108 | } 109 | 110 | interface PycodestyleCategorySeverity { 111 | readonly W: DiagnosticSeverity; 112 | readonly E: DiagnosticSeverity; 113 | } 114 | interface Flake8CategorySeverity { 115 | readonly F: DiagnosticSeverity; 116 | readonly E: DiagnosticSeverity; 117 | readonly W: DiagnosticSeverity; 118 | } 119 | interface MypyCategorySeverity { 120 | readonly error: DiagnosticSeverity; 121 | readonly note: DiagnosticSeverity; 122 | } 123 | 124 | export interface ILintingSettings { 125 | readonly enabled: boolean; 126 | readonly lintOnSave: boolean; 127 | readonly ignorePatterns: string[]; 128 | readonly maxNumberOfProblems: number; 129 | readonly banditEnabled: boolean; 130 | readonly banditArgs: string[]; 131 | readonly flake8Enabled: boolean; 132 | readonly flake8Args: string[]; 133 | readonly flake8CategorySeverity: Flake8CategorySeverity; 134 | readonly mypyEnabled: boolean; 135 | readonly mypyArgs: string[]; 136 | readonly mypyCategorySeverity: MypyCategorySeverity; 137 | readonly ruffEnabled: boolean; 138 | readonly prospectorEnabled: boolean; 139 | readonly prospectorArgs: string[]; 140 | readonly pylintEnabled: boolean; 141 | readonly pylintArgs: string[]; 142 | readonly pylintCategorySeverity: PylintCategorySeverity; 143 | readonly pycodestyleEnabled: boolean; 144 | readonly pycodestyleArgs: string[]; 145 | readonly pycodestyleCategorySeverity: PycodestyleCategorySeverity; 146 | readonly pyflakesEnabled: boolean; 147 | readonly pylamaEnabled: boolean; 148 | readonly pylamaArgs: string[]; 149 | readonly pydocstyleEnabled: boolean; 150 | readonly pydocstyleArgs: string[]; 151 | banditPath: string; 152 | flake8Path: string; 153 | mypyPath: string; 154 | ruffPath: string; 155 | prospectorPath: string; 156 | pylintPath: string; 157 | pycodestylePath: string; 158 | pyflakesPath: string; 159 | pylamaPath: string; 160 | pydocstylePath: string; 161 | } 162 | export interface IFormattingSettings { 163 | readonly provider: FormatterId; 164 | autopep8Path: string; 165 | readonly autopep8Args: string[]; 166 | blackPath: string; 167 | readonly blackArgs: string[]; 168 | pyinkPath: string; 169 | readonly pyinkArgs: string[]; 170 | yapfPath: string; 171 | readonly yapfArgs: string[]; 172 | ruffPath: string; 173 | readonly ruffArgs: string[]; 174 | darkerPath: string; 175 | readonly darkerArgs: string[]; 176 | blackdPath: string; 177 | readonly blackdHTTPURL: string; 178 | readonly blackdHTTPHeaders: Record; 179 | } 180 | export interface ISortImportSettings { 181 | path: string; 182 | readonly args: string[]; 183 | } 184 | 185 | export interface IPythonSettings { 186 | pythonPath: string; 187 | readonly stdLibs: string[]; 188 | readonly linting: ILintingSettings; 189 | readonly formatting: IFormattingSettings; 190 | readonly sortImports: ISortImportSettings; 191 | } 192 | 193 | export namespace LinterErrors { 194 | export namespace pylint { 195 | export const InvalidSyntax = 'E0001'; 196 | } 197 | export namespace prospector { 198 | export const InvalidSyntax = 'F999'; 199 | } 200 | export namespace flake8 { 201 | export const InvalidSyntax = 'E999'; 202 | } 203 | } 204 | 205 | export interface IStringDictionary { 206 | [name: string]: V; 207 | } 208 | 209 | export interface ISystemVariables { 210 | resolve(value: string): string; 211 | resolve(value: string[]): string[]; 212 | resolve(value: IStringDictionary): IStringDictionary; 213 | resolve(value: IStringDictionary): IStringDictionary; 214 | resolve(value: IStringDictionary>): IStringDictionary>; 215 | resolveAny(value: T): T; 216 | [key: string]: any; 217 | } 218 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | type CompletionContext, 4 | type CompletionItem, 5 | CompletionItemKind, 6 | type ConfigurationParams, 7 | type Diagnostic, 8 | type HandleDiagnosticsSignature, 9 | InsertTextFormat, 10 | type LinesTextDocument, 11 | type Position, 12 | type ProvideCompletionItemsSignature, 13 | type ProvideHoverSignature, 14 | type ProvideSignatureHelpSignature, 15 | type ResolveCompletionItemSignature, 16 | type SignatureHelpContext, 17 | workspace, 18 | } from 'coc.nvim'; 19 | import { PythonSettings } from './configSettings'; 20 | 21 | function toJSONObject(obj: any): any { 22 | if (obj) { 23 | if (Array.isArray(obj)) { 24 | return obj.map(toJSONObject); 25 | } 26 | if (typeof obj === 'object') { 27 | const res = Object.create(null); 28 | for (const key in obj) { 29 | // biome-ignore lint/suspicious/noPrototypeBuiltins: x 30 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 31 | res[key] = toJSONObject(obj[key]); 32 | } 33 | } 34 | return res; 35 | } 36 | } 37 | return obj; 38 | } 39 | 40 | export function configuration(params: ConfigurationParams, token: CancellationToken, next: any) { 41 | const pythonItem = params.items.find((x) => x.section === 'python'); 42 | if (pythonItem) { 43 | const custom = () => { 44 | const config = toJSONObject(workspace.getConfiguration(pythonItem.section, pythonItem.scopeUri)); 45 | config['pythonPath'] = PythonSettings.getInstance().pythonPath; 46 | 47 | // expand relative path 48 | const analysis = config['analysis']; 49 | analysis['stubPath'] = workspace.expand(analysis['stubPath'] as string); 50 | const inspect = workspace.getConfiguration('python.analysis').inspect('stubPath'); 51 | if ( 52 | inspect && 53 | (inspect.globalValue === undefined || 54 | inspect.workspaceValue === undefined || 55 | inspect.workspaceFolderValue === undefined) 56 | ) { 57 | analysis['stubPath'] = undefined; 58 | } 59 | const extraPaths = analysis['extraPaths'] as string[]; 60 | if (extraPaths?.length) { 61 | analysis['extraPaths'] = extraPaths.map((p) => workspace.expand(p)); 62 | } 63 | const typeshedPaths = analysis['typeshedPaths'] as string[]; 64 | if (typeshedPaths?.length) { 65 | analysis['typeshedPaths'] = typeshedPaths.map((p) => workspace.expand(p)); 66 | } 67 | config['analysis'] = analysis; 68 | return [config]; 69 | }; 70 | return custom(); 71 | } 72 | const analysisItem = params.items.find((x) => x.section === 'python.analysis'); 73 | if (analysisItem) { 74 | const custom = () => { 75 | const analysis = toJSONObject(workspace.getConfiguration(analysisItem.section, analysisItem.scopeUri)); 76 | analysis['stubPath'] = workspace.expand(analysis['stubPath'] as string); 77 | const inspect = workspace.getConfiguration('python.analysis').inspect('stubPath'); 78 | if ( 79 | inspect && 80 | (inspect.globalValue === undefined || 81 | inspect.workspaceValue === undefined || 82 | inspect.workspaceFolderValue === undefined) 83 | ) { 84 | analysis['stubPath'] = undefined; 85 | } 86 | const extraPaths = analysis['extraPaths'] as string[]; 87 | if (extraPaths?.length) { 88 | analysis['extraPaths'] = extraPaths.map((p) => workspace.expand(p)); 89 | } 90 | const typeshedPaths = analysis['typeshedPaths'] as string[]; 91 | if (typeshedPaths?.length) { 92 | analysis['typeshedPaths'] = typeshedPaths.map((p) => workspace.expand(p)); 93 | } 94 | return [analysis]; 95 | }; 96 | 97 | return custom(); 98 | } 99 | 100 | return next(params, token); 101 | } 102 | 103 | export async function provideCompletionItem( 104 | document: LinesTextDocument, 105 | position: Position, 106 | context: CompletionContext, 107 | token: CancellationToken, 108 | next: ProvideCompletionItemsSignature, 109 | ) { 110 | const result = await next(document, position, context, token); 111 | if (!result) return; 112 | 113 | const items = Array.isArray(result) ? result : result.items; 114 | // biome-ignore lint/suspicious/noAssignInExpressions: ignore 115 | items.map((x) => (x.sortText ? (x.sortText = x.sortText.toLowerCase()) : (x.sortText = x.label.toLowerCase()))); 116 | 117 | const snippetSupport = workspace.getConfiguration('pyright').get('completion.snippetSupport'); 118 | if (snippetSupport) { 119 | for (const item of items) { 120 | if (item.data?.funcParensDisabled) continue; 121 | if (item.kind === CompletionItemKind.Method || item.kind === CompletionItemKind.Function) { 122 | item.insertText = `${item.label}($1)$0`; 123 | item.insertTextFormat = InsertTextFormat.Snippet; 124 | } 125 | } 126 | } 127 | 128 | return Array.isArray(result) ? items : { items, isIncomplete: result.isIncomplete }; 129 | } 130 | 131 | export async function resolveCompletionItem( 132 | item: CompletionItem, 133 | token: CancellationToken, 134 | next: ResolveCompletionItemSignature, 135 | ) { 136 | const result = await next(item, token); 137 | if ( 138 | result && 139 | typeof result.documentation === 'object' && 140 | 'kind' in result.documentation && 141 | result.documentation.kind === 'markdown' 142 | ) { 143 | result.documentation.value = result.documentation.value.replace(/ /g, ' '); 144 | } 145 | return result; 146 | } 147 | 148 | export async function provideHover( 149 | document: LinesTextDocument, 150 | position: Position, 151 | token: CancellationToken, 152 | next: ProvideHoverSignature, 153 | ) { 154 | const hover = await next(document, position, token); 155 | if (hover && typeof hover.contents === 'object' && 'kind' in hover.contents && hover.contents.kind === 'markdown') { 156 | hover.contents.value = hover.contents.value.replace(/ /g, ' '); 157 | } 158 | return hover; 159 | } 160 | 161 | export async function provideSignatureHelp( 162 | document: LinesTextDocument, 163 | position: Position, 164 | context: SignatureHelpContext, 165 | token: CancellationToken, 166 | next: ProvideSignatureHelpSignature, 167 | ) { 168 | const sign = await next(document, position, context, token); 169 | if (sign?.signatures.length) { 170 | for (const info of sign.signatures) { 171 | if (info.documentation && typeof info.documentation === 'object' && info.documentation.kind === 'markdown') { 172 | info.documentation.value = info.documentation.value.replace(/ /g, ' '); 173 | } 174 | } 175 | } 176 | 177 | return sign; 178 | } 179 | 180 | export async function handleDiagnostics(uri: string, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) { 181 | next( 182 | uri, 183 | diagnostics.filter((d) => d.message !== '"__" is not accessed'), 184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /src/features/codeAction.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CancellationTokenSource, 3 | type CodeAction, 4 | type CodeActionContext, 5 | CodeActionKind, 6 | type CodeActionProvider, 7 | type CompleteOption, 8 | type Diagnostic, 9 | Position, 10 | Range, 11 | type TextDocument, 12 | TextEdit, 13 | type VimCompleteItem, 14 | sources, 15 | workspace, 16 | } from 'coc.nvim'; 17 | 18 | export class PythonCodeActionProvider implements CodeActionProvider { 19 | private wholeRange(doc: TextDocument, range: Range): boolean { 20 | const whole = Range.create(0, 0, doc.lineCount - 1, 0); 21 | return ( 22 | whole.start.line === range.start.line && 23 | whole.start.character === range.start.character && 24 | whole.end.line === range.end.line && 25 | whole.end.character === range.end.character 26 | ); 27 | } 28 | 29 | private cursorRange(r: Range): boolean { 30 | return r.start.line === r.end.line && r.start.character === r.end.character; 31 | } 32 | 33 | private lineRange(r: Range): boolean { 34 | return ( 35 | (r.start.line + 1 === r.end.line && r.start.character === 0 && r.end.character === 0) || 36 | (r.start.line === r.end.line && r.start.character === 0) 37 | ); 38 | } 39 | 40 | private sortImportsAction(): CodeAction { 41 | const config = workspace.getConfiguration('pyright'); 42 | const provider = config.get<'pyright' | 'isort' | 'ruff'>('organizeimports.provider', 'pyright'); 43 | const command = provider === 'pyright' ? 'pyright.organizeimports' : 'python.sortImports'; 44 | const title = provider === 'pyright' ? 'Organize Imports by Pyright' : `Sort Imports by ${provider}`; 45 | return { 46 | title, 47 | kind: CodeActionKind.SourceOrganizeImports, 48 | command: { 49 | title: '', 50 | command, 51 | }, 52 | }; 53 | } 54 | 55 | private ignoreAction(document: TextDocument, range: Range): CodeAction | null { 56 | const ignoreTxt = '# type: ignore'; 57 | const doc = workspace.getDocument(document.uri); 58 | if (!doc) { 59 | return null; 60 | } 61 | // ignore action for whole file 62 | if (this.wholeRange(document, range)) { 63 | let pos = Position.create(0, 0); 64 | if (doc.getline(0).startsWith('#!')) pos = Position.create(1, 0); 65 | if (!doc.getline(pos.line).includes(ignoreTxt)) { 66 | return { 67 | title: 'Ignore Pyright typing check for whole file', 68 | kind: CodeActionKind.Empty, 69 | edit: { 70 | changes: { 71 | [doc.uri]: [TextEdit.insert(pos, `${ignoreTxt}\n`)], 72 | }, 73 | }, 74 | }; 75 | } 76 | } 77 | 78 | // ignore action for current line 79 | if (this.lineRange(range)) { 80 | const line = doc.getline(range.start.line); 81 | if (line.length && !line.startsWith('#') && !line.includes(ignoreTxt)) { 82 | const edit = TextEdit.replace( 83 | range, 84 | `${line} ${ignoreTxt}${range.start.line + 1 === range.end.line ? '\n' : ''}`, 85 | ); 86 | return { 87 | title: 'Ignore Pyright typing check for current line', 88 | kind: CodeActionKind.Empty, 89 | edit: { 90 | changes: { 91 | [doc.uri]: [edit], 92 | }, 93 | }, 94 | }; 95 | } 96 | } 97 | return null; 98 | } 99 | 100 | private extractActions(document: TextDocument, range: Range): CodeAction[] { 101 | return [ 102 | // extract actions should only work on range text 103 | { 104 | title: 'Extract Method', 105 | kind: CodeActionKind.RefactorExtract, 106 | command: { 107 | command: 'python.refactorExtractMethod', 108 | title: '', 109 | arguments: [document, range], 110 | }, 111 | }, 112 | 113 | { 114 | title: 'Extract Variable', 115 | kind: CodeActionKind.RefactorExtract, 116 | command: { 117 | title: '', 118 | command: 'python.refactorExtractVariable', 119 | arguments: [document, range], 120 | }, 121 | }, 122 | ]; 123 | } 124 | 125 | private async fetchImportsByDiagnostic( 126 | document: TextDocument, 127 | diag: Diagnostic, 128 | ): Promise> { 129 | const match = diag.message.match(/"(.*)" is not defined/); 130 | if (!match) return []; 131 | 132 | const source = sources.sources.find((s) => s.name.includes('pyright')); 133 | if (!source) return []; 134 | 135 | // @ts-expect-error 136 | const option: CompleteOption = { position: diag.range.end, bufnr: document.uri }; 137 | const tokenSource = new CancellationTokenSource(); 138 | const result = await source.doComplete(option, tokenSource.token); 139 | tokenSource.cancel(); 140 | 141 | // @ts-expect-error 142 | return result ? result.items.filter((x) => x.label === match[1]) : []; 143 | } 144 | 145 | private async fixAction(document: TextDocument, diag: Diagnostic): Promise { 146 | const actions: CodeAction[] = []; 147 | if (diag.code === 'reportUndefinedVariable') { 148 | const items = await this.fetchImportsByDiagnostic(document, diag); 149 | for (const item of items) { 150 | // @ts-expect-error 151 | const changes: TextEdit[] = [item.textEdit].concat(item.additionalTextEdits ?? []); 152 | // @ts-expect-error 153 | const title = item.documentation?.value.replace('```\n', '').replace('\n```', '').trim(); 154 | actions.push({ 155 | title, 156 | kind: CodeActionKind.QuickFix, 157 | edit: { 158 | changes: { 159 | [document.uri]: changes, 160 | }, 161 | }, 162 | }); 163 | } 164 | } 165 | 166 | // @ts-expect-error 167 | if (diag.fix) { 168 | actions.push({ 169 | // @ts-expect-error 170 | title: diag.fix.title, 171 | kind: CodeActionKind.QuickFix, 172 | // @ts-expect-error 173 | edit: diag.fix.edit, 174 | }); 175 | } 176 | 177 | return actions; 178 | } 179 | 180 | public async provideCodeActions( 181 | document: TextDocument, 182 | range: Range, 183 | context: CodeActionContext, 184 | ): Promise { 185 | const actions: CodeAction[] = []; 186 | 187 | if (context.diagnostics.length) { 188 | for (const diag of context.diagnostics) { 189 | actions.push(...(await this.fixAction(document, diag))); 190 | } 191 | } 192 | 193 | // sort imports actions 194 | actions.push(this.sortImportsAction()); 195 | 196 | // ignore actions 197 | const ignore = this.ignoreAction(document, range); 198 | if (ignore) actions.push(ignore); 199 | 200 | // extract actions 201 | if (!this.wholeRange(document, range) && !this.cursorRange(range)) { 202 | actions.push(...this.extractActions(document, range)); 203 | } 204 | 205 | return actions; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/features/formatters/baseFormatter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | type FormattingOptions, 4 | type OutputChannel, 5 | type Range, 6 | type TextDocument, 7 | type TextEdit, 8 | type Thenable, 9 | Uri, 10 | window, 11 | workspace, 12 | } from 'coc.nvim'; 13 | import fs from 'node:fs'; 14 | import md5 from 'md5'; 15 | import path from 'node:path'; 16 | import which from 'which'; 17 | import { isNotInstalledError, PythonExecutionService } from '../../processService'; 18 | import type { ExecutionInfo, FormatterId, IPythonSettings } from '../../types'; 19 | import { getTextEditsFromPatch } from '../../utils'; 20 | 21 | function getTempFileWithDocumentContents(document: TextDocument): Promise { 22 | return new Promise((resolve, reject) => { 23 | const fsPath = Uri.parse(document.uri).fsPath; 24 | const ext = path.extname(fsPath); 25 | // Don't create file in temp folder since external utilities 26 | // look into configuration files in the workspace and are not able 27 | // to find custom rules if file is saved in a random disk location. 28 | // This means temp file has to be created in the same folder 29 | // as the original one and then removed. 30 | 31 | const fileName = `${fsPath}.${md5(document.uri)}${ext}`; 32 | fs.writeFile(fileName, document.getText(), (ex) => { 33 | if (ex) { 34 | reject(new Error(`Failed to create a temporary file, ${ex.message}`)); 35 | } 36 | resolve(fileName); 37 | }); 38 | }); 39 | } 40 | 41 | export abstract class BaseFormatter { 42 | constructor( 43 | public readonly Id: FormatterId, 44 | public readonly pythonSettings: IPythonSettings, 45 | public readonly outputChannel: OutputChannel, 46 | ) {} 47 | 48 | public abstract formatDocument( 49 | document: TextDocument, 50 | options: FormattingOptions, 51 | token: CancellationToken, 52 | range?: Range, 53 | ): Thenable; 54 | protected getDocumentPath(document: TextDocument, fallbackPath?: string): string { 55 | const filepath = Uri.parse(document.uri).fsPath; 56 | if (fallbackPath && path.basename(filepath) === filepath) { 57 | return fallbackPath; 58 | } 59 | return path.dirname(filepath); 60 | } 61 | protected getWorkspaceUri(document: TextDocument): Uri | undefined { 62 | const filepath = Uri.parse(document.uri).fsPath; 63 | if (!filepath.startsWith(workspace.root)) return; 64 | return Uri.file(workspace.root); 65 | } 66 | 67 | private getExecutionInfo(args: string[]): ExecutionInfo { 68 | let moduleName: string | undefined; 69 | let execPath = this.pythonSettings.formatting[`${this.Id}Path`] as string; 70 | execPath = which.sync(execPath, { nothrow: true }) || execPath; 71 | if (path.basename(execPath) === execPath) { 72 | moduleName = execPath; 73 | } 74 | 75 | return { execPath, moduleName, args }; 76 | } 77 | 78 | protected async provideDocumentFormattingEdits( 79 | document: TextDocument, 80 | _options: FormattingOptions, 81 | token: CancellationToken, 82 | args: string[], 83 | root?: string, 84 | ): Promise { 85 | if (this.pythonSettings.stdLibs.some((p) => Uri.parse(document.uri).fsPath.startsWith(p))) { 86 | return []; 87 | } 88 | // autopep8 and yapf have the ability to read from the process input stream and return the formatted code out of the output stream. 89 | // However they don't support returning the diff of the formatted text when reading data from the input stream. 90 | // Yet getting text formatted that way avoids having to create a temporary file, however the diffing will have 91 | // to be done here in node (extension), i.e. extension CPU, i.e. less responsive solution. 92 | const filepath = Uri.parse(document.uri).fsPath; 93 | const tempFile = await this.createTempFile(document); 94 | if (this.checkCancellation(filepath, tempFile, 'start', token)) { 95 | return []; 96 | } 97 | args.push(tempFile); 98 | 99 | const executionInfo = this.getExecutionInfo(args); 100 | this.outputChannel.appendLine(`execPath: ${executionInfo.execPath}`); 101 | this.outputChannel.appendLine(`moduleName: ${executionInfo.moduleName}`); 102 | this.outputChannel.appendLine(`args: ${executionInfo.args}`); 103 | 104 | const cwd = root?.length ? root : Uri.file(workspace.root).fsPath; 105 | const pythonToolsExecutionService = new PythonExecutionService(); 106 | const promise = pythonToolsExecutionService 107 | .exec(executionInfo, { cwd, throwOnStdErr: false, token }) 108 | .then((output) => { 109 | if (output.stderr) { 110 | throw new Error(output.stderr); 111 | } 112 | return output.stdout; 113 | }) 114 | .then((data) => { 115 | this.outputChannel.appendLine(''); 116 | this.outputChannel.appendLine(`${'#'.repeat(10)} ${this.Id} output:`); 117 | this.outputChannel.appendLine(data); 118 | if (this.checkCancellation(filepath, tempFile, 'success', token)) { 119 | return [] as TextEdit[]; 120 | } 121 | const edits = getTextEditsFromPatch(document.getText(), data); 122 | if (edits.length) window.showInformationMessage(`Formatted with ${this.Id}`); 123 | return edits; 124 | }) 125 | .catch((error) => { 126 | this.handleError(this.Id, error).catch(() => {}); 127 | if (this.checkCancellation(filepath, tempFile, 'error', token)) { 128 | return [] as TextEdit[]; 129 | } 130 | return [] as TextEdit[]; 131 | }) 132 | .finally(() => { 133 | this.deleteTempFile(filepath, tempFile).catch(() => {}); 134 | }); 135 | return promise; 136 | } 137 | 138 | protected async handleError(_expectedFileName: string, error: Error) { 139 | this.outputChannel.appendLine(`${'#'.repeat(10)} Formatting with ${this.Id} failed`); 140 | this.outputChannel.appendLine(error.message); 141 | 142 | let customError = `Formatting with ${this.Id} failed`; 143 | if (isNotInstalledError(error)) { 144 | customError = `${customError}: ${this.Id} module is not installed.`; 145 | } 146 | window.showWarningMessage(customError); 147 | } 148 | 149 | protected createTempFile(document: TextDocument): Promise { 150 | return getTempFileWithDocumentContents(document); 151 | } 152 | 153 | private deleteTempFile(originalFile: string, tempFile: string): Promise { 154 | if (originalFile !== tempFile) { 155 | return fs.promises.unlink(tempFile); 156 | } 157 | return Promise.resolve(); 158 | } 159 | 160 | private checkCancellation(originalFile: string, tempFile: string, state: string, token?: CancellationToken): boolean { 161 | if (token?.isCancellationRequested) { 162 | this.outputChannel.appendLine(`${'#'.repeat(10)} ${this.Id} formatting action is canceled on ${state}`); 163 | this.deleteTempFile(originalFile, tempFile).catch(() => {}); 164 | return true; 165 | } 166 | return false; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/parsers/semanticTokens.ts: -------------------------------------------------------------------------------- 1 | import { ParseTreeWalker } from '@zzzen/pyright-internal/dist/analyzer/parseTreeWalker'; 2 | import { 3 | type CallNode, 4 | type ClassNode, 5 | type DecoratorNode, 6 | type ForNode, 7 | type FormatStringNode, 8 | type FunctionNode, 9 | type ImportAsNode, 10 | type ImportFromAsNode, 11 | type ImportFromNode, 12 | type ImportNode, 13 | type MemberAccessNode, 14 | type NameNode, 15 | type ParameterNode, 16 | type ParseNode, 17 | type ParseNodeBase, 18 | ParseNodeType, 19 | type TypeAnnotationNode, 20 | type TypeParameterNode, 21 | } from '@zzzen/pyright-internal/dist/parser/parseNodes'; 22 | import { SemanticTokenModifiers, SemanticTokenTypes } from 'coc.nvim'; 23 | 24 | type SemanticTokenItem = { 25 | type: string; 26 | start: number; 27 | length: number; 28 | modifiers: string[]; 29 | }; 30 | 31 | export class SemanticTokensWalker extends ParseTreeWalker { 32 | public semanticItems: SemanticTokenItem[] = []; 33 | 34 | private addItem(node: ParseNodeBase, type: string, modifiers: string[] = []) { 35 | const item: SemanticTokenItem = { type, start: node.start, length: node.length, modifiers }; 36 | if (this.semanticItems.some((x) => x.type === item.type && x.start === item.start && x.length === item.length)) { 37 | return; 38 | } 39 | 40 | this.semanticItems.push(item); 41 | } 42 | 43 | visit(node: ParseNode): boolean { 44 | // ParseNodeType.Argument; 45 | // console.error(node); 46 | return super.visit(node); 47 | } 48 | 49 | visitFor(node: ForNode): boolean { 50 | if (node.nodeType === ParseNodeType.For) { 51 | this.addItem(node.d.targetExpr, SemanticTokenTypes.variable); 52 | } 53 | return super.visitFor(node); 54 | } 55 | 56 | visitFormatString(node: FormatStringNode): boolean { 57 | node.d.fieldExprs.map((f) => this.addItem(f, SemanticTokenTypes.variable)); 58 | return super.visitFormatString(node); 59 | } 60 | 61 | visitCall(node: CallNode): boolean { 62 | // TODO: hard-code, treat first-letter UpperCase as class 63 | if (node.d.leftExpr.nodeType === 38) { 64 | const value = node.d.leftExpr.d.value; 65 | if (value[0] === value[0].toUpperCase()) { 66 | this.addItem(node.d.leftExpr, SemanticTokenTypes.class); 67 | } else { 68 | this.addItem(node.d.leftExpr, SemanticTokenTypes.function); 69 | } 70 | } 71 | return super.visitCall(node); 72 | } 73 | 74 | visitClass(node: ClassNode): boolean { 75 | // @ts-expect-error 76 | if (node.d.arguments.length === 1 && node.d.arguments[0].d.valueExpr.d.value === 'Enum') { 77 | this.addItem(node.d.name, SemanticTokenTypes.enum); 78 | 79 | for (const m of node.d.suite.d.statements) { 80 | // @ts-expect-error 81 | this.addItem(m.d.statements[0].d.leftExpr, SemanticTokenTypes.enumMember); 82 | } 83 | return super.visitClass(node); 84 | } 85 | 86 | this.addItem(node.d.name, SemanticTokenTypes.class, [SemanticTokenModifiers.definition]); 87 | return super.visitClass(node); 88 | } 89 | 90 | visitMemberAccess(node: MemberAccessNode): boolean { 91 | if (node.parent?.nodeType === ParseNodeType.Call) { 92 | this.addItem(node.d.member, SemanticTokenTypes.function); 93 | return super.visitMemberAccess(node); 94 | } 95 | 96 | this.addItem(node.d.member, SemanticTokenTypes.property); 97 | return super.visitMemberAccess(node); 98 | } 99 | 100 | visitDecorator(node: DecoratorNode): boolean { 101 | this.addItem(node.d.expr, SemanticTokenTypes.decorator); 102 | let nameNode: NameNode | undefined; 103 | switch (node.d.expr.nodeType) { 104 | case ParseNodeType.Call: 105 | if (node.d.expr.d.leftExpr.nodeType === ParseNodeType.MemberAccess) { 106 | nameNode = node.d.expr.d.leftExpr.d.member; 107 | } else if (node.d.expr.d.leftExpr.nodeType === ParseNodeType.Name) { 108 | nameNode = node.d.expr.d.leftExpr; 109 | } 110 | break; 111 | case ParseNodeType.MemberAccess: 112 | nameNode = node.d.expr.d.member; 113 | break; 114 | case ParseNodeType.Name: 115 | nameNode = node.d.expr; 116 | break; 117 | } 118 | if (nameNode) { 119 | this.addItem(nameNode, SemanticTokenTypes.decorator); 120 | } 121 | return super.visitDecorator(node); 122 | } 123 | 124 | visitImport(node: ImportNode): boolean { 125 | for (const x of node.d.list) { 126 | if (x.d.alias) { 127 | this.addItem(x.d.alias, SemanticTokenTypes.namespace); 128 | } 129 | } 130 | return super.visitImport(node); 131 | } 132 | 133 | visitImportAs(node: ImportAsNode): boolean { 134 | if (node.d.alias?.d.value.length) { 135 | this.addItem(node.d.alias, SemanticTokenTypes.namespace); 136 | } 137 | node.d.module.d.nameParts.map((x) => this.addItem(x, SemanticTokenTypes.namespace)); 138 | return super.visitImportAs(node); 139 | } 140 | 141 | visitImportFrom(node: ImportFromNode): boolean { 142 | node.d.module.d.nameParts.map((x) => this.addItem(x, SemanticTokenTypes.namespace)); 143 | for (const x of node.d.imports) { 144 | if (x.d.alias) { 145 | this.addItem(x.d.alias, SemanticTokenTypes.namespace); 146 | } 147 | } 148 | 149 | return super.visitImportFrom(node); 150 | } 151 | 152 | visitImportFromAs(node: ImportFromAsNode): boolean { 153 | if (node.d.alias?.d.value.length) { 154 | this.addItem(node.d.alias, SemanticTokenTypes.namespace); 155 | } 156 | return super.visitImportFromAs(node); 157 | } 158 | 159 | visitParameter(node: ParameterNode): boolean { 160 | if (!node.d.name) return super.visitParameter(node); 161 | 162 | this.addItem(node.d.name, SemanticTokenTypes.parameter); 163 | if (node.d.annotation) { 164 | this.addItem(node.d.annotation, SemanticTokenTypes.typeParameter); 165 | } 166 | return super.visitParameter(node); 167 | } 168 | 169 | visitTypeParameter(node: TypeParameterNode): boolean { 170 | this.addItem(node.d.name, SemanticTokenTypes.typeParameter); 171 | return super.visitTypeParameter(node); 172 | } 173 | 174 | visitTypeAnnotation(node: TypeAnnotationNode): boolean { 175 | if (node.d.annotation) { 176 | this.addItem(node.d.annotation, SemanticTokenTypes.typeParameter); 177 | } 178 | return super.visitTypeAnnotation(node); 179 | } 180 | 181 | visitFunction(node: FunctionNode): boolean { 182 | const modifiers = [SemanticTokenModifiers.definition]; 183 | if (node.d.isAsync) { 184 | modifiers.push(SemanticTokenModifiers.async); 185 | } 186 | const type = node.parent?.parent?.nodeType === 10 ? SemanticTokenTypes.method : SemanticTokenTypes.function; 187 | this.addItem(node.d.name, type, modifiers); 188 | 189 | for (const p of node.d.params) { 190 | if (!p.d.name) continue; 191 | 192 | this.addItem(p.d.name, SemanticTokenTypes.parameter); 193 | if (p.d.annotation) { 194 | this.addItem(p.d.annotation, SemanticTokenTypes.typeParameter); 195 | } 196 | } 197 | 198 | return super.visitFunction(node); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/features/linters/baseLinter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { spawn } from 'node:child_process'; 5 | import { type CancellationToken, type OutputChannel, type TextDocument, Uri, workspace } from 'coc.nvim'; 6 | import namedRegexp from 'named-js-regexp'; 7 | import { PythonSettings } from '../../configSettings'; 8 | import { PythonExecutionService } from '../../processService'; 9 | import { 10 | type ExecutionInfo, 11 | type ILinter, 12 | type ILinterInfo, 13 | type ILintMessage, 14 | type IPythonSettings, 15 | type LinterId, 16 | LintMessageSeverity, 17 | } from '../../types'; 18 | import { splitLines } from '../../utils'; 19 | 20 | // Allow negative column numbers (https://github.com/PyCQA/pylint/issues/1822) 21 | const REGEX = '(?\\d+),(?-?\\d+),(?\\w+),(?\\w+\\d+):(?.*)\\r?(\\n|$)'; 22 | 23 | interface IRegexGroup { 24 | line: number; 25 | column: number; 26 | code: string; 27 | message: string; 28 | type: string; 29 | file?: string; 30 | } 31 | 32 | function matchNamedRegEx(data: string, regex: string): IRegexGroup | undefined { 33 | const compiledRegexp = namedRegexp(regex, 'g'); 34 | const rawMatch = compiledRegexp.exec(data); 35 | if (rawMatch) { 36 | // @ts-expect-error 37 | return rawMatch.groups() as IRegexGroup; 38 | } 39 | 40 | return undefined; 41 | } 42 | 43 | function parseLine(line: string, regex: string, linterID: LinterId, colOffset = 0): ILintMessage | undefined { 44 | const match = matchNamedRegEx(line, regex)!; 45 | if (!match) { 46 | return; 47 | } 48 | 49 | match.line = Number(match.line as any); 50 | match.column = Number(match.column as any); 51 | 52 | return { 53 | code: match.code, 54 | message: match.message, 55 | column: Number.isNaN(match.column) || match.column <= 0 ? 0 : match.column - colOffset, 56 | line: match.line, 57 | type: match.type, 58 | provider: linterID, 59 | file: match.file, 60 | }; 61 | } 62 | 63 | export abstract class BaseLinter implements ILinter { 64 | protected readonly isWindows = process.platform === 'win32'; 65 | 66 | private _pythonSettings: IPythonSettings; 67 | private _info: ILinterInfo; 68 | 69 | protected get pythonSettings(): IPythonSettings { 70 | return this._pythonSettings; 71 | } 72 | 73 | constructor( 74 | info: ILinterInfo, 75 | protected readonly outputChannel: OutputChannel, 76 | protected readonly columnOffset = 0, 77 | ) { 78 | this._info = info; 79 | this._pythonSettings = PythonSettings.getInstance(); 80 | } 81 | 82 | public get info(): ILinterInfo { 83 | return this._info; 84 | } 85 | 86 | public async lint(document: TextDocument, cancellation: CancellationToken): Promise { 87 | return this.runLinter(document, cancellation); 88 | } 89 | 90 | protected abstract runLinter(document: TextDocument, cancellation: CancellationToken): Promise; 91 | 92 | protected parseMessagesSeverity(error: string, categorySeverity: any): LintMessageSeverity { 93 | if (categorySeverity[error]) { 94 | const severityName = categorySeverity[error]; 95 | switch (severityName) { 96 | case 'Error': 97 | return LintMessageSeverity.Error; 98 | case 'Hint': 99 | return LintMessageSeverity.Hint; 100 | case 'Information': 101 | return LintMessageSeverity.Information; 102 | case 'Warning': 103 | return LintMessageSeverity.Warning; 104 | default: { 105 | if (LintMessageSeverity[severityName]) { 106 | return LintMessageSeverity[severityName] as any as LintMessageSeverity; 107 | } 108 | } 109 | } 110 | } 111 | return LintMessageSeverity.Information; 112 | } 113 | 114 | private async stdinRun(executionInfo: ExecutionInfo, document: TextDocument): Promise { 115 | let command = executionInfo.execPath; 116 | let args = executionInfo.args; 117 | if (executionInfo.moduleName) { 118 | command = this.pythonSettings.pythonPath; 119 | args = ['-m', executionInfo.moduleName, ...args]; 120 | } 121 | const child = spawn(command, args, { cwd: workspace.root }); 122 | return new Promise((resolve) => { 123 | child.stdin.setDefaultEncoding('utf8'); 124 | child.stdin.write(document.getText()); 125 | child.stdin.end(); 126 | 127 | let result = ''; 128 | child.stdout.on('data', (data) => { 129 | result += data.toString('utf-8').trim(); 130 | }); 131 | child.on('close', () => { 132 | resolve(result); 133 | }); 134 | }); 135 | } 136 | 137 | protected async run( 138 | args: string[], 139 | document: TextDocument, 140 | token: CancellationToken, 141 | regEx = REGEX, 142 | ): Promise { 143 | if (!this.info.isEnabled(Uri.parse(document.uri))) { 144 | return []; 145 | } 146 | 147 | try { 148 | const executionInfo = this.info.getExecutionInfo(args, Uri.parse(document.uri)); 149 | this.outputChannel.appendLine(`${'#'.repeat(10)} Run linter ${this.info.id}:`); 150 | this.outputChannel.appendLine(JSON.stringify(executionInfo)); 151 | this.outputChannel.appendLine(''); 152 | 153 | let result = ''; 154 | if (this.info.stdinSupport) { 155 | result = await this.stdinRun(executionInfo, document); 156 | } else { 157 | const service = new PythonExecutionService(); 158 | result = (await service.exec(executionInfo, { cwd: workspace.root, token, mergeStdOutErr: false })).stdout; 159 | } 160 | 161 | this.outputChannel.append(`${'#'.repeat(10)} Linting Output - ${this.info.id} ${'#'.repeat(10)}\n`); 162 | this.outputChannel.append(result); 163 | this.outputChannel.appendLine(''); 164 | 165 | return await this.parseMessages(result, document, regEx); 166 | } catch (error) { 167 | this.outputChannel.appendLine(`Linting with ${this.info.id} failed:`); 168 | if (error instanceof Error) { 169 | this.outputChannel.appendLine(error.message.toString()); 170 | } 171 | return []; 172 | } 173 | } 174 | 175 | protected async parseMessages(output: string, _document: TextDocument, regEx: string) { 176 | const messages: ILintMessage[] = []; 177 | const outputLines = splitLines(output, { removeEmptyEntries: false, trim: false }); 178 | for (const line of outputLines) { 179 | try { 180 | const msg = parseLine(line, regEx, this.info.id, this.columnOffset); 181 | if (msg) { 182 | messages.push(msg); 183 | if (messages.length >= this.pythonSettings.linting.maxNumberOfProblems) { 184 | break; 185 | } 186 | } 187 | } catch (err) { 188 | this.outputChannel.appendLine(`${'#'.repeat(10)} Linter ${this.info.id} failed to parse the line:`); 189 | this.outputChannel.appendLine(line); 190 | if (typeof err === 'string') { 191 | this.outputChannel.appendLine(err); 192 | } else if (err instanceof Error) { 193 | this.outputChannel.appendLine(err.message); 194 | } 195 | } 196 | } 197 | return messages; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/features/inlayHints.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CancellationToken, 3 | Emitter, 4 | type Event, 5 | type Hover, 6 | type InlayHint, 7 | type InlayHintLabelPart, 8 | type InlayHintsProvider, 9 | type LanguageClient, 10 | type LinesTextDocument, 11 | type MarkupContent, 12 | Position, 13 | type Range, 14 | type SignatureHelp, 15 | workspace, 16 | } from 'coc.nvim'; 17 | 18 | import * as parser from '../parsers'; 19 | import { positionInRange } from '../utils'; 20 | 21 | export class TypeInlayHintsProvider implements InlayHintsProvider { 22 | private readonly _onDidChangeInlayHints = new Emitter(); 23 | public readonly onDidChangeInlayHints: Event = this._onDidChangeInlayHints.event; 24 | 25 | constructor(private client: LanguageClient) { 26 | workspace.onDidChangeConfiguration((e) => { 27 | if (e.affectsConfiguration('pyright.inlayHints')) { 28 | this._onDidChangeInlayHints.fire(); 29 | } 30 | }); 31 | workspace.onDidChangeTextDocument((e) => { 32 | const doc = workspace.getDocument(e.bufnr); 33 | if (doc?.languageId === 'python') { 34 | this._onDidChangeInlayHints.fire(); 35 | } 36 | }); 37 | } 38 | 39 | async provideInlayHints(document: LinesTextDocument, range: Range, token: CancellationToken): Promise { 40 | const inlayHints: InlayHint[] = []; 41 | 42 | const code = document.getText(); 43 | const parsed = parser.parse(code); 44 | if (!parsed) return []; 45 | 46 | const walker = new parser.TypeInlayHintsWalker(parsed); 47 | walker.walk(parsed.parserOutput.parseTree); 48 | 49 | const featureItems = walker.featureItems 50 | .filter((item) => this.enableForType(item.hintType)) 51 | .filter((item) => { 52 | const startPosition = document.positionAt(item.startOffset); 53 | const endPosition = document.positionAt(item.endOffset); 54 | return positionInRange(startPosition, range) === 0 || positionInRange(endPosition, range) === 0; 55 | }); 56 | if (featureItems.length === 0) return []; 57 | 58 | for (const item of featureItems) { 59 | const startPosition = document.positionAt(item.startOffset); 60 | const endPosition = document.positionAt(item.endOffset); 61 | const hover = 62 | item.hintType === 'parameter' ? null : await this.getHoverAtPosition(document, startPosition, token); 63 | const signatureHelp = 64 | item.hintType === 'parameter' ? await this.getSignatureHelpAtPosition(document, startPosition, token) : null; 65 | 66 | let inlayHintLabelValue: string | undefined; 67 | switch (item.hintType) { 68 | case 'variable': 69 | inlayHintLabelValue = this.getVariableHintFromHover(hover); 70 | break; 71 | case 'functionReturn': 72 | inlayHintLabelValue = this.getFunctionReturnHintFromHover(hover); 73 | break; 74 | case 'parameter': 75 | inlayHintLabelValue = this.getParameterHintFromSignature(signatureHelp); 76 | break; 77 | default: 78 | break; 79 | } 80 | if (!inlayHintLabelValue) { 81 | continue; 82 | } 83 | 84 | const inlayHintLabelPart: InlayHintLabelPart[] = [ 85 | { 86 | value: inlayHintLabelValue, 87 | }, 88 | ]; 89 | 90 | let inlayHintPosition: Position | undefined; 91 | switch (item.hintType) { 92 | case 'variable': 93 | inlayHintPosition = Position.create(startPosition.line, endPosition.character + 1); 94 | break; 95 | case 'functionReturn': 96 | inlayHintPosition = endPosition; 97 | break; 98 | case 'parameter': 99 | inlayHintPosition = startPosition; 100 | break; 101 | default: 102 | break; 103 | } 104 | 105 | if (inlayHintPosition) { 106 | inlayHints.push({ 107 | label: inlayHintLabelPart, 108 | position: inlayHintPosition, 109 | kind: item.hintType === 'parameter' ? 2 : 1, 110 | paddingLeft: item.hintType === 'functionReturn', 111 | }); 112 | } 113 | } 114 | 115 | return inlayHints; 116 | } 117 | 118 | private async getHoverAtPosition(document: LinesTextDocument, position: Position, token: CancellationToken) { 119 | const params = { 120 | textDocument: { uri: document.uri }, 121 | position, 122 | }; 123 | 124 | const result = await Promise.race([ 125 | this.client.sendRequest('textDocument/hover', params, token), 126 | new Promise((resolve) => { 127 | setTimeout(() => { 128 | resolve(null); 129 | }, 200); 130 | }), 131 | ]); 132 | return result; 133 | } 134 | 135 | private getVariableHintFromHover(hover: Hover | null): string | undefined { 136 | if (!hover) return; 137 | const contents = hover.contents as MarkupContent; 138 | if (contents.value.includes('(variable)')) { 139 | if (contents.value.includes('(variable) def')) { 140 | return; 141 | } 142 | const firstIdx = contents.value.indexOf(': '); 143 | if (firstIdx > -1) { 144 | const text = contents.value 145 | .substring(firstIdx + 2) 146 | .split('\n')[0] 147 | .trim(); 148 | if (text === 'Any' || text.startsWith('Literal[')) { 149 | return; 150 | } 151 | return `: ${text}`; 152 | } 153 | } 154 | } 155 | 156 | private getFunctionReturnHintFromHover(hover: Hover | null): string | undefined { 157 | if (!hover) return; 158 | const contents = hover.contents as MarkupContent; 159 | if (contents && (contents.value.includes('(function)') || contents.value.includes('(method)'))) { 160 | const retvalIdx = contents.value.indexOf('->') + 2; 161 | const text = contents.value.substring(retvalIdx).split('\n')[0].trim(); 162 | return `-> ${text}`; 163 | } 164 | } 165 | 166 | private async getSignatureHelpAtPosition(document: LinesTextDocument, position: Position, token: CancellationToken) { 167 | const params = { 168 | textDocument: { uri: document.uri }, 169 | position, 170 | }; 171 | 172 | const result = await Promise.race([ 173 | this.client.sendRequest('textDocument/signatureHelp', params, token), 174 | new Promise((resolve) => { 175 | setTimeout(() => { 176 | resolve(null); 177 | }, 200); 178 | }), 179 | ]); 180 | return result; 181 | } 182 | 183 | private getParameterHintFromSignature(signatureInfo: SignatureHelp | null): string | undefined { 184 | if (!signatureInfo) return; 185 | const sig = signatureInfo.signatures[0]; 186 | if (typeof sig.activeParameter !== 'number') { 187 | return; 188 | } 189 | if (!sig.parameters || sig.parameters.length < sig.activeParameter) { 190 | return; 191 | } 192 | const param = sig.parameters[sig.activeParameter]; 193 | if (typeof param.label === 'string') { 194 | return param.label; 195 | } 196 | const label = sig.label.substring(param.label[0], param.label[1]).split(':')[0]; 197 | if (label.startsWith('__')) { 198 | return; 199 | } 200 | return `${label}: `; 201 | } 202 | 203 | private enableForType(inlayHintType: string) { 204 | return workspace.getConfiguration('pyright').get(`inlayHints.${inlayHintType}Types`, true); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/processService.ts: -------------------------------------------------------------------------------- 1 | import { type ExecOptions, spawn, type SpawnOptions as ChildProcessSpawnOptions } from 'node:child_process'; 2 | import type { CancellationToken } from 'coc.nvim'; 3 | import * as iconv from 'iconv-lite'; 4 | import { homedir } from 'node:os'; 5 | import { Observable } from 'rxjs'; 6 | import { createDeferred } from './async'; 7 | import { PythonSettings } from './configSettings'; 8 | import type { ExecutionInfo, ExecutionResult, ObservableExecutionResult, Output } from './types'; 9 | 10 | const DEFAULT_ENCODING = 'utf8'; 11 | 12 | type ShellOptions = ExecOptions & { throwOnStdErr?: boolean }; 13 | type SpawnOptions = ChildProcessSpawnOptions & { 14 | encoding?: string; 15 | token?: CancellationToken; 16 | mergeStdOutErr?: boolean; 17 | throwOnStdErr?: boolean; 18 | }; 19 | 20 | class BufferDecoder { 21 | public decode(buffers: Buffer[], value: string = DEFAULT_ENCODING): string { 22 | const encoding = iconv.encodingExists(value) ? value : DEFAULT_ENCODING; 23 | return iconv.decode(Buffer.concat(buffers), encoding); 24 | } 25 | } 26 | 27 | class ModuleNotInstalledError extends Error { 28 | constructor(moduleName: string) { 29 | super(`Module '${moduleName}' not installed.`); 30 | } 31 | } 32 | 33 | export function isNotInstalledError(error: Error): boolean { 34 | const isError = typeof error === 'object' && error !== null; 35 | const errorObj = error; 36 | if (!isError) { 37 | return false; 38 | } 39 | if (error instanceof ModuleNotInstalledError) { 40 | return true; 41 | } 42 | 43 | const isModuleNoInstalledError = error.message.indexOf('No module named') >= 0; 44 | return errorObj.code === 'ENOENT' || errorObj.code === 127 || isModuleNoInstalledError; 45 | } 46 | 47 | class ProcessService { 48 | private readonly decoder = new BufferDecoder(); 49 | 50 | public static isAlive(pid: number): boolean { 51 | try { 52 | process.kill(pid, 0); 53 | return true; 54 | } catch { 55 | return false; 56 | } 57 | } 58 | public static kill(pid: number): void { 59 | const killProcessTree = require('tree-kill'); 60 | try { 61 | killProcessTree(pid); 62 | } catch { 63 | // Ignore. 64 | } 65 | } 66 | 67 | public execObservable(file: string, args: string[], options: SpawnOptions = {}): ObservableExecutionResult { 68 | const spawnOptions = this.getDefaultOptions(options); 69 | const encoding = spawnOptions.encoding ? spawnOptions.encoding : DEFAULT_ENCODING; 70 | const proc = spawn(file, args, spawnOptions); 71 | let procExited = false; 72 | 73 | const output = new Observable>((subscriber) => { 74 | if (options.token) { 75 | options.token.onCancellationRequested(() => { 76 | if (!procExited && !proc.killed) { 77 | proc.kill(); 78 | procExited = true; 79 | } 80 | }); 81 | } 82 | 83 | const sendOutput = (source: 'stdout' | 'stderr', data: Buffer) => { 84 | const out = this.decoder.decode([data], encoding); 85 | if (source === 'stderr' && options.throwOnStdErr) { 86 | subscriber.error(new Error(out)); 87 | } else { 88 | subscriber.next({ source, out }); 89 | } 90 | }; 91 | proc.stdout!.on('data', (data: Buffer) => sendOutput('stdout', data)); 92 | proc.stderr!.on('data', (data: Buffer) => sendOutput('stderr', data)); 93 | 94 | const onExit = (ex?: any) => { 95 | if (procExited) return; 96 | procExited = true; 97 | if (ex) subscriber.error(ex); 98 | subscriber.complete(); 99 | }; 100 | 101 | proc.once('close', () => { 102 | onExit(); 103 | }); 104 | proc.once('error', onExit); 105 | }); 106 | 107 | return { 108 | proc, 109 | out: output, 110 | dispose: () => { 111 | if (proc && !proc.killed) { 112 | ProcessService.kill(proc.pid as number); 113 | } 114 | }, 115 | }; 116 | } 117 | 118 | public exec(file: string, args: string[], options: SpawnOptions = {}): Promise> { 119 | const cmd = file.startsWith('~/') ? file.replace('~', homedir()) : file; 120 | const spawnOptions = this.getDefaultOptions(options); 121 | const encoding = spawnOptions.encoding ? spawnOptions.encoding : DEFAULT_ENCODING; 122 | const proc = spawn(cmd, args, spawnOptions); 123 | const deferred = createDeferred>(); 124 | 125 | if (options.token) { 126 | options.token.onCancellationRequested(() => { 127 | if (!proc.killed && !deferred.completed) { 128 | proc.kill(); 129 | } 130 | }); 131 | } 132 | 133 | const stdoutBuffers: Buffer[] = []; 134 | proc.stdout!.on('data', (data: Buffer) => stdoutBuffers.push(data)); 135 | const stderrBuffers: Buffer[] = []; 136 | proc.stderr!.on('data', (data: Buffer) => { 137 | if (options.mergeStdOutErr) { 138 | stdoutBuffers.push(data); 139 | stderrBuffers.push(data); 140 | } else { 141 | stderrBuffers.push(data); 142 | } 143 | }); 144 | 145 | proc.once('close', () => { 146 | if (deferred.completed) { 147 | return; 148 | } 149 | const stderr = stderrBuffers.length === 0 ? undefined : this.decoder.decode(stderrBuffers, encoding); 150 | if (stderr && stderr.length > 0 && options.throwOnStdErr) { 151 | deferred.reject(new Error(stderr)); 152 | } else { 153 | const stdout = this.decoder.decode(stdoutBuffers, encoding); 154 | deferred.resolve({ stdout, stderr }); 155 | } 156 | }); 157 | proc.once('error', (ex) => { 158 | console.error('once error:', ex); 159 | deferred.reject(ex); 160 | }); 161 | 162 | return deferred.promise; 163 | } 164 | 165 | private getDefaultOptions(options: T): T { 166 | const defaultOptions = { ...options }; 167 | if (!defaultOptions.env || Object.keys(defaultOptions.env).length === 0) { 168 | defaultOptions.env = { ...process.env }; 169 | } else { 170 | defaultOptions.env = { ...defaultOptions.env }; 171 | } 172 | 173 | // Always ensure we have unbuffered output. 174 | defaultOptions.env.PYTHONUNBUFFERED = '1'; 175 | if (!defaultOptions.env.PYTHONIOENCODING) { 176 | defaultOptions.env.PYTHONIOENCODING = 'utf-8'; 177 | } 178 | 179 | return defaultOptions; 180 | } 181 | } 182 | 183 | export class PythonExecutionService { 184 | private readonly procService = new ProcessService(); 185 | private readonly pythonSettings = PythonSettings.getInstance(); 186 | 187 | public async isModuleInstalled(moduleName: string): Promise { 188 | return this.procService 189 | .exec(this.pythonSettings.pythonPath, ['-c', `import ${moduleName}`], { throwOnStdErr: true }) 190 | .then(() => true) 191 | .catch(() => false); 192 | } 193 | 194 | public execObservable(cmd: string, args: string[], options: SpawnOptions): ObservableExecutionResult { 195 | const opts: SpawnOptions = { ...options }; 196 | return this.procService.execObservable(cmd, args, opts); 197 | } 198 | 199 | async exec(executionInfo: ExecutionInfo, options: SpawnOptions): Promise> { 200 | const opts: SpawnOptions = { ...options }; 201 | const { execPath, moduleName, args } = executionInfo; 202 | 203 | if (moduleName && moduleName.length > 0) { 204 | const result = await this.procService.exec(this.pythonSettings.pythonPath, ['-m', moduleName, ...args], opts); 205 | 206 | // If a module is not installed we'll have something in stderr. 207 | if ( 208 | result.stderr && 209 | (result.stderr.indexOf(`No module named ${moduleName}`) > 0 || 210 | result.stderr.indexOf(`No module named '${moduleName}'`) > 0) 211 | ) { 212 | throw new ModuleNotInstalledError(moduleName!); 213 | } 214 | 215 | return result; 216 | } 217 | 218 | return this.procService.exec(execPath, args, opts); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commands, 3 | type DocumentSelector, 4 | type ExtensionContext, 5 | extensions, 6 | LanguageClient, 7 | type LanguageClientOptions, 8 | languages, 9 | type Range, 10 | type ServerOptions, 11 | services, 12 | type StaticFeature, 13 | type TextDocument, 14 | TransportKind, 15 | Uri, 16 | window, 17 | workspace, 18 | } from 'coc.nvim'; 19 | import { existsSync, readFileSync } from 'node:fs'; 20 | import { join } from 'node:path'; 21 | import which from 'which'; 22 | import { runFileTest, runSingleTest } from './commands'; 23 | import { PythonSettings } from './configSettings'; 24 | import { PythonCodeActionProvider } from './features/codeAction'; 25 | import { PythonFormattingEditProvider } from './features/formatting'; 26 | import { ImportCompletionProvider } from './features/importCompletion'; 27 | import { TypeInlayHintsProvider } from './features/inlayHints'; 28 | import { PythonSemanticTokensProvider } from './features/semanticTokens'; 29 | import { sortImports } from './features/sortImports'; 30 | import { LinterProvider } from './features/lintting'; 31 | import { extractMethod, extractVariable } from './features/refactor'; 32 | import { TestFrameworkProvider } from './features/testing'; 33 | import { 34 | configuration, 35 | handleDiagnostics, 36 | provideCompletionItem, 37 | provideHover, 38 | provideSignatureHelp, 39 | resolveCompletionItem, 40 | } from './middleware'; 41 | 42 | const defaultHeapSize = 3072; 43 | 44 | const method = 'workspace/executeCommand'; 45 | const documentSelector: DocumentSelector = [ 46 | { 47 | scheme: 'file', 48 | language: 'python', 49 | }, 50 | ]; 51 | 52 | class PyrightExtensionFeature implements StaticFeature { 53 | dispose(): void {} 54 | initialize() {} 55 | fillClientCapabilities(capabilities: any) { 56 | // Pyright set activeParameter = -1 when activeParameterSupport enabled 57 | // this will break signatureHelp 58 | capabilities.textDocument.signatureHelp.signatureInformation.activeParameterSupport = false; 59 | } 60 | } 61 | 62 | export async function activate(context: ExtensionContext): Promise { 63 | const pyrightCfg = workspace.getConfiguration('pyright'); 64 | const isEnable = pyrightCfg.get('enable', true); 65 | if (!isEnable) return; 66 | 67 | const state = extensions.getExtensionState('coc-python'); 68 | if (state.toString() === 'activated') { 69 | window.showWarningMessage('coc-python is installed and activated, coc-pyright will be disabled'); 70 | return; 71 | } 72 | let module = pyrightCfg.get('server'); 73 | if (module) { 74 | module = which.sync(workspace.expand(module), { nothrow: true }) || module; 75 | } else { 76 | module = join(context.extensionPath, 'node_modules', 'pyright', 'langserver.index.js'); 77 | } 78 | if (!existsSync(module)) { 79 | window.showErrorMessage(`Pyright langserver doesn't exist, please reinstall coc-pyright`); 80 | return; 81 | } 82 | 83 | const runOptions = { execArgv: [`--max-old-space-size=${defaultHeapSize}`] }; 84 | const debugOptions = { execArgv: ['--nolazy', '--inspect=6600', `--max-old-space-size=${defaultHeapSize}`] }; 85 | 86 | const serverOptions: ServerOptions = { 87 | run: { module: module, transport: TransportKind.ipc, options: runOptions }, 88 | debug: { module: module, transport: TransportKind.ipc, options: debugOptions }, 89 | }; 90 | 91 | const disabledFeatures: string[] = []; 92 | if (pyrightCfg.get('disableCompletion')) { 93 | disabledFeatures.push('completion'); 94 | } 95 | if (pyrightCfg.get('disableDiagnostics')) { 96 | disabledFeatures.push('diagnostics'); 97 | disabledFeatures.push('pullDiagnostic'); 98 | } 99 | if (pyrightCfg.get('disableDocumentation')) { 100 | disabledFeatures.push('hover'); 101 | } 102 | const disableProgress = pyrightCfg.get('disableProgressNotifications'); 103 | if (disableProgress) { 104 | disabledFeatures.push('progress'); 105 | } 106 | const outputChannel = window.createOutputChannel('Pyright'); 107 | const pythonSettings = PythonSettings.getInstance(); 108 | outputChannel.appendLine(`Workspace: ${workspace.root}`); 109 | outputChannel.appendLine(`Using python from ${pythonSettings.pythonPath}\n`); 110 | const clientOptions: LanguageClientOptions = { 111 | documentSelector, 112 | synchronize: { 113 | configurationSection: ['python', 'pyright'], 114 | }, 115 | outputChannel, 116 | disabledFeatures, 117 | progressOnInitialization: !disableProgress, 118 | middleware: { 119 | workspace: { 120 | configuration, 121 | }, 122 | provideHover, 123 | provideSignatureHelp, 124 | provideCompletionItem, 125 | handleDiagnostics, 126 | resolveCompletionItem, 127 | }, 128 | }; 129 | 130 | const client: LanguageClient = new LanguageClient('pyright', 'Pyright Server', serverOptions, clientOptions); 131 | client.registerFeature(new PyrightExtensionFeature()); 132 | context.subscriptions.push(services.registLanguageClient(client)); 133 | 134 | const formatProvider = new PythonFormattingEditProvider(); 135 | context.subscriptions.push(languages.registerDocumentFormatProvider(documentSelector, formatProvider)); 136 | context.subscriptions.push(languages.registerDocumentRangeFormatProvider(documentSelector, formatProvider)); 137 | 138 | context.subscriptions.push(new LinterProvider(context)); 139 | 140 | const codeActionProvider = new PythonCodeActionProvider(); 141 | context.subscriptions.push(languages.registerCodeActionProvider(documentSelector, codeActionProvider, 'Pyright')); 142 | 143 | const importSupport = pyrightCfg.get('completion.importSupport'); 144 | if (importSupport) { 145 | const provider = new ImportCompletionProvider(); 146 | context.subscriptions.push( 147 | languages.registerCompletionItemProvider('python-import', 'PY', ['python'], provider, [' ']), 148 | ); 149 | } 150 | const inlayHintEnable = workspace.getConfiguration('inlayHint').get('enable', true); 151 | if (inlayHintEnable && typeof languages.registerInlayHintsProvider === 'function') { 152 | const provider = new TypeInlayHintsProvider(client); 153 | context.subscriptions.push(languages.registerInlayHintsProvider(documentSelector, provider)); 154 | } 155 | const semanticTokensEnable = workspace.getConfiguration('semanticTokens').get('enable', true); 156 | if (semanticTokensEnable && typeof languages.registerDocumentSemanticTokensProvider === 'function') { 157 | const provider = new PythonSemanticTokensProvider(); 158 | context.subscriptions.push( 159 | languages.registerDocumentSemanticTokensProvider(documentSelector, provider, provider.legend), 160 | ); 161 | } 162 | const testProvider = new TestFrameworkProvider(); 163 | context.subscriptions.push(languages.registerCodeActionProvider(documentSelector, testProvider, 'Pyright')); 164 | const codeLens = workspace.getConfiguration('codeLens').get('enable', false); 165 | if (codeLens) context.subscriptions.push(languages.registerCodeLensProvider(documentSelector, testProvider)); 166 | 167 | const textEditorCommands = ['pyright.organizeimports', 'pyright.addoptionalforparam']; 168 | for (const commandName of textEditorCommands) { 169 | context.subscriptions.push( 170 | commands.registerCommand(commandName, async (offset: number) => { 171 | const doc = await workspace.document; 172 | const cmd = { 173 | command: commandName, 174 | arguments: [doc.uri.toString(), offset], 175 | }; 176 | 177 | await client.sendRequest(method, cmd); 178 | }), 179 | ); 180 | } 181 | 182 | let command = 'pyright.restartserver'; 183 | let disposable = commands.registerCommand(command, async () => { 184 | await client.sendRequest(method, { command }); 185 | }); 186 | context.subscriptions.push(disposable); 187 | 188 | command = 'pyright.createtypestub'; 189 | disposable = commands.registerCommand(command, async (...args: any[]) => { 190 | if (!args.length) { 191 | window.showWarningMessage('Module name is missing'); 192 | return; 193 | } 194 | const doc = await workspace.document; 195 | const filePath = Uri.parse(doc.uri).fsPath; 196 | if (args[args.length - 1] !== filePath) { 197 | // args from Pyright : [root, module, filePath] 198 | // args from CocCommand: [module] 199 | args.unshift(workspace.root); 200 | args.push(filePath); 201 | } 202 | 203 | const cmd = { 204 | command, 205 | arguments: args, 206 | }; 207 | await client.sendRequest(method, cmd); 208 | }); 209 | context.subscriptions.push(disposable); 210 | 211 | disposable = commands.registerCommand( 212 | 'python.refactorExtractVariable', 213 | async (document: TextDocument, range: Range) => { 214 | await extractVariable(context.extensionPath, document, range, outputChannel).catch(() => {}); 215 | }, 216 | null, 217 | true, 218 | ); 219 | context.subscriptions.push(disposable); 220 | 221 | disposable = commands.registerCommand( 222 | 'python.refactorExtractMethod', 223 | async (document: TextDocument, range: Range) => { 224 | await extractMethod(context.extensionPath, document, range, outputChannel).catch(() => {}); 225 | }, 226 | null, 227 | true, 228 | ); 229 | context.subscriptions.push(disposable); 230 | 231 | disposable = commands.registerCommand('python.sortImports', async () => { 232 | await sortImports(outputChannel).catch(() => {}); 233 | }); 234 | context.subscriptions.push(disposable); 235 | 236 | disposable = commands.registerCommand('pyright.fileTest', async () => { 237 | await runFileTest(); 238 | }); 239 | context.subscriptions.push(disposable); 240 | 241 | disposable = commands.registerCommand('pyright.singleTest', async () => { 242 | await runSingleTest(); 243 | }); 244 | context.subscriptions.push(disposable); 245 | 246 | disposable = commands.registerCommand('pyright.version', () => { 247 | const pyrightJSON = join(context.extensionPath, 'node_modules', 'pyright', 'package.json'); 248 | const pyrightPackage = JSON.parse(readFileSync(pyrightJSON, 'utf8')); 249 | const cocPyrightJSON = join(context.extensionPath, 'package.json'); 250 | const cocPyrightPackage = JSON.parse(readFileSync(cocPyrightJSON, 'utf8')); 251 | window.showInformationMessage(`coc-pyright ${cocPyrightPackage.version} with Pyright ${pyrightPackage.version}`); 252 | }); 253 | context.subscriptions.push(disposable); 254 | } 255 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { type Position, Range, type TextDocument, TextEdit, Uri } from 'coc.nvim'; 2 | import { type Diff, diff_match_patch } from 'diff-match-patch'; 3 | import fs from 'node:fs'; 4 | import md5 from 'md5'; 5 | import { EOL } from 'node:os'; 6 | import * as path from 'node:path'; 7 | const NEW_LINE_LENGTH = EOL.length; 8 | 9 | enum EditAction { 10 | Delete = 0, 11 | Insert = 1, 12 | Replace = 2, 13 | } 14 | 15 | class Patch { 16 | public diffs!: Diff[]; 17 | public start1!: number; 18 | public start2!: number; 19 | public length1!: number; 20 | public length2!: number; 21 | } 22 | 23 | class Edit { 24 | public action: EditAction; 25 | public start: Position; 26 | public end!: Position; 27 | public text: string; 28 | 29 | constructor(action: number, start: Position) { 30 | this.action = action; 31 | this.start = start; 32 | this.text = ''; 33 | } 34 | 35 | public apply(): TextEdit { 36 | switch (this.action) { 37 | case EditAction.Insert: 38 | return TextEdit.insert(this.start, this.text); 39 | case EditAction.Delete: 40 | return TextEdit.del(Range.create(this.start, this.end)); 41 | case EditAction.Replace: 42 | return TextEdit.replace(Range.create(this.start, this.end), this.text); 43 | default: 44 | return { 45 | range: Range.create(0, 0, 0, 0), 46 | newText: '', 47 | }; 48 | } 49 | } 50 | } 51 | 52 | function getTextEditsInternal(before: string, diffs: [number, string][], startLine = 0): Edit[] { 53 | let line = startLine; 54 | let character = 0; 55 | if (line > 0) { 56 | const beforeLines = before.split(/\r?\n/g).filter((_l, i) => i < line); 57 | for (const l of beforeLines) { 58 | character += l.length + NEW_LINE_LENGTH; 59 | } 60 | } 61 | const edits: Edit[] = []; 62 | let edit: Edit | null = null; 63 | 64 | for (let i = 0; i < diffs.length; i += 1) { 65 | const start = { line, character }; 66 | // Compute the line/character after the diff is applied. 67 | for (let curr = 0; curr < diffs[i][1].length; curr += 1) { 68 | if (diffs[i][1][curr] !== '\n') { 69 | character += 1; 70 | } else { 71 | character = 0; 72 | line += 1; 73 | } 74 | } 75 | 76 | const dmp = require('diff-match-patch') as typeof import('diff-match-patch'); 77 | switch (diffs[i][0]) { 78 | case dmp.DIFF_DELETE: 79 | if (edit === null) { 80 | edit = new Edit(EditAction.Delete, start); 81 | } else if (edit.action !== EditAction.Delete) { 82 | throw new Error('cannot format due to an internal error.'); 83 | } 84 | edit.end = { line, character }; 85 | break; 86 | 87 | case dmp.DIFF_INSERT: 88 | if (edit === null) { 89 | edit = new Edit(EditAction.Insert, start); 90 | } else if (edit.action === EditAction.Delete) { 91 | edit.action = EditAction.Replace; 92 | } 93 | // insert and replace edits are all relative to the original state 94 | // of the document, so inserts should reset the current line/character 95 | // position to the start. 96 | line = start.line; 97 | character = start.character; 98 | edit.text += diffs[i][1]; 99 | break; 100 | 101 | case dmp.DIFF_EQUAL: 102 | if (edit !== null) { 103 | edits.push(edit); 104 | edit = null; 105 | } 106 | break; 107 | } 108 | } 109 | 110 | if (edit !== null) { 111 | edits.push(edit); 112 | } 113 | 114 | return edits; 115 | } 116 | 117 | /** 118 | * Parse a textual representation of patches and return a list of Patch objects. 119 | * @param {string} textline Text representation of patches. 120 | * @return {!Array.} Array of Patch objects. 121 | * @throws {!Error} If invalid input. 122 | */ 123 | function patch_fromText(textline: string): Patch[] { 124 | const patches: Patch[] = []; 125 | if (!textline) { 126 | return patches; 127 | } 128 | // Start Modification by Don Jayamanne 24/06/2016 Support for CRLF 129 | const text = textline.split(/[\r\n]/); 130 | // End Modification 131 | let textPointer = 0; 132 | const patchHeader = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/; 133 | while (textPointer < text.length) { 134 | const m = text[textPointer].match(patchHeader); 135 | if (!m) { 136 | throw new Error(`Invalid patch string: ${text[textPointer]}`); 137 | } 138 | 139 | const patch = new (diff_match_patch as any).patch_obj(); 140 | patches.push(patch); 141 | patch.start1 = parseInt(m[1], 10); 142 | if (m[2] === '') { 143 | patch.start1 -= 1; 144 | patch.length1 = 1; 145 | } else if (m[2] === '0') { 146 | patch.length1 = 0; 147 | } else { 148 | patch.start1 -= 1; 149 | patch.length1 = parseInt(m[2], 10); 150 | } 151 | 152 | patch.start2 = parseInt(m[3], 10); 153 | if (m[4] === '') { 154 | patch.start2 -= 1; 155 | patch.length2 = 1; 156 | } else if (m[4] === '0') { 157 | patch.length2 = 0; 158 | } else { 159 | patch.start2 -= 1; 160 | patch.length2 = parseInt(m[4], 10); 161 | } 162 | textPointer += 1; 163 | const dmp = require('diff-match-patch') as typeof import('diff-match-patch'); 164 | 165 | while (textPointer < text.length) { 166 | const sign = text[textPointer].charAt(0); 167 | let line: string; 168 | try { 169 | // var line = decodeURI(text[textPointer].substring(1)) 170 | // For some reason the patch generated by python files don't encode any characters 171 | // And this patch module (code from Google) is expecting the text to be encoded!! 172 | // Temporary solution, disable decoding 173 | // Issue #188 174 | line = text[textPointer].substring(1); 175 | } catch (_ex) { 176 | // Malformed URI sequence. 177 | throw new Error('Illegal escape in patch_fromText'); 178 | } 179 | if (sign === '-') { 180 | // Deletion. 181 | patch.diffs.push([dmp.DIFF_DELETE, line]); 182 | } else if (sign === '+') { 183 | // Insertion. 184 | patch.diffs.push([dmp.DIFF_INSERT, line]); 185 | } else if (sign === ' ') { 186 | // Minor equality. 187 | patch.diffs.push([dmp.DIFF_EQUAL, line]); 188 | } else if (sign === '@') { 189 | // Start of next patch. 190 | break; 191 | } else if (sign === '') { 192 | // Blank line? Whatever. 193 | } else { 194 | // WTF? 195 | throw new Error(`Invalid patch mode '${sign}' in: ${line}`); 196 | } 197 | textPointer += 1; 198 | } 199 | } 200 | return patches; 201 | } 202 | 203 | export function getTextEditsFromPatch(before: string, input: string): TextEdit[] { 204 | let patch = input; 205 | 206 | if (patch.startsWith('---')) { 207 | // Strip the first two lines 208 | patch = patch.substring(patch.indexOf('@@')); 209 | } 210 | 211 | if (patch.length === 0) { 212 | return []; 213 | } 214 | // Remove the text added by unified_diff 215 | // # Work around missing newline (http://bugs.python.org/issue2142). 216 | patch = patch.replace(/\\ No newline at end of file[\r\n]/, ''); 217 | const dmp = require('diff-match-patch') as typeof import('diff-match-patch'); 218 | const d = new dmp.diff_match_patch(); 219 | const patches = patch_fromText.call(d, patch); 220 | if (!Array.isArray(patches) || patches.length === 0) { 221 | throw new Error('Unable to parse Patch string'); 222 | } 223 | const textEdits: TextEdit[] = []; 224 | 225 | // Add line feeds and build the text edits 226 | for (const p of patches) { 227 | for (const diff of p.diffs) { 228 | diff[1] += EOL; 229 | } 230 | for (const edit of getTextEditsInternal(before, p.diffs, p.start1)) { 231 | textEdits.push(edit.apply()); 232 | } 233 | } 234 | 235 | return textEdits; 236 | } 237 | 238 | export function splitLines(str: string, splitOptions: { trim: boolean; removeEmptyEntries: boolean }): string[] { 239 | let lines = str.split(/\r?\n/g); 240 | if (splitOptions.trim) { 241 | lines = lines.map((line) => line.trim()); 242 | } 243 | if (splitOptions.removeEmptyEntries) { 244 | lines = lines.filter((line) => line.length > 0); 245 | } 246 | return lines; 247 | } 248 | 249 | export function getWindowsLineEndingCount(document: TextDocument, offset: number) { 250 | const eolPattern = /\r\n/g; 251 | const readBlock = 1024; 252 | let count = 0; 253 | let offsetDiff = offset.valueOf(); 254 | 255 | // In order to prevent the one-time loading of large files from taking up too much memory 256 | for (let pos = 0; pos < offset; pos += readBlock) { 257 | const startAt = document.positionAt(pos); 258 | 259 | let endAt: Position; 260 | if (offsetDiff >= readBlock) { 261 | endAt = document.positionAt(pos + readBlock); 262 | offsetDiff = offsetDiff - readBlock; 263 | } else { 264 | endAt = document.positionAt(pos + offsetDiff); 265 | } 266 | 267 | const text = document.getText(Range.create(startAt, endAt!)); 268 | const cr = text.match(eolPattern); 269 | 270 | count += cr ? cr.length : 0; 271 | } 272 | return count; 273 | } 274 | 275 | export function getTempFileWithDocumentContents(document: TextDocument): Promise { 276 | return new Promise((resolve, reject) => { 277 | const fsPath = Uri.parse(document.uri).fsPath; 278 | const fileName = `${fsPath.slice(0, -3)}${md5(document.uri)}${path.extname(fsPath)}`; 279 | fs.writeFile(fileName, document.getText(), (ex) => { 280 | if (ex) { 281 | reject(new Error(`Failed to create a temporary file, ${ex.message}`)); 282 | } 283 | resolve(fileName); 284 | }); 285 | }); 286 | } 287 | 288 | function comparePosition(position: Position, other: Position): number { 289 | if (position.line > other.line) return 1; 290 | if (other.line === position.line && position.character > other.character) return 1; 291 | if (other.line === position.line && position.character === other.character) return 0; 292 | return -1; 293 | } 294 | 295 | export function positionInRange(position: Position, range: Range): number { 296 | const { start, end } = range; 297 | if (comparePosition(position, start) < 0) return -1; 298 | if (comparePosition(position, end) > 0) return 1; 299 | return 0; 300 | } 301 | 302 | export function rangeInRange(r: Range, range: Range): boolean { 303 | return positionInRange(r.start, range) === 0 && positionInRange(r.end, range) === 0; 304 | } 305 | -------------------------------------------------------------------------------- /src/configSettings.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from 'node:child_process'; 2 | import { type ConfigurationChangeEvent, type Disposable, workspace, type WorkspaceConfiguration } from 'coc.nvim'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import which from 'which'; 6 | import { SystemVariables } from './systemVariables'; 7 | import type { IFormattingSettings, ILintingSettings, IPythonSettings, ISortImportSettings } from './types'; 8 | 9 | export class PythonSettings implements IPythonSettings { 10 | private workspaceRoot: string; 11 | 12 | private static pythonSettings: Map = new Map(); 13 | public linting!: ILintingSettings; 14 | public formatting!: IFormattingSettings; 15 | public sortImports!: ISortImportSettings; 16 | 17 | private disposables: Disposable[] = []; 18 | private _pythonPath = ''; 19 | private _stdLibs: string[] = []; 20 | 21 | constructor() { 22 | this.workspaceRoot = workspace.root ? workspace.root : __dirname; 23 | this.initialize(); 24 | } 25 | 26 | public static getInstance(): PythonSettings { 27 | const workspaceFolder = workspace.workspaceFolders.length > 0 ? workspace.workspaceFolders[0] : undefined; 28 | const workspaceFolderKey = workspaceFolder ? workspaceFolder.name : 'unknown'; 29 | 30 | if (!PythonSettings.pythonSettings.has(workspaceFolderKey)) { 31 | const settings = new PythonSettings(); 32 | PythonSettings.pythonSettings.set(workspaceFolderKey, settings); 33 | } 34 | return PythonSettings.pythonSettings.get(workspaceFolderKey)!; 35 | } 36 | 37 | public static dispose() { 38 | for (const item of PythonSettings.pythonSettings) { 39 | item[1].dispose(); 40 | } 41 | PythonSettings.pythonSettings.clear(); 42 | } 43 | 44 | public dispose() { 45 | for (const disposable of this.disposables) { 46 | disposable.dispose(); 47 | } 48 | this.disposables = []; 49 | } 50 | 51 | private resolvePythonFromVENV(): string | undefined { 52 | function pythonBinFromPath(p: string): string | undefined { 53 | const fullPath = 54 | process.platform === 'win32' ? path.join(p, 'Scripts', 'python.exe') : path.join(p, 'bin', 'python'); 55 | return fs.existsSync(fullPath) ? fullPath : undefined; 56 | } 57 | 58 | try { 59 | // virtualenv 60 | if (process.env.VIRTUAL_ENV && fs.existsSync(path.join(process.env.VIRTUAL_ENV, 'pyvenv.cfg'))) { 61 | return pythonBinFromPath(process.env.VIRTUAL_ENV); 62 | } 63 | 64 | // conda 65 | if (process.env.CONDA_PREFIX) { 66 | return pythonBinFromPath(process.env.CONDA_PREFIX); 67 | } 68 | 69 | // uv creates `.python-version` and `.venv` 70 | let p = path.join(this.workspaceRoot, 'uv.lock'); 71 | const p2 = path.join(this.workspaceRoot, '.venv'); 72 | if (fs.existsSync(p) && fs.existsSync(p2)) { 73 | return pythonBinFromPath(p2); 74 | } 75 | 76 | // `pyenv local` creates `.python-version`, but not `PYENV_VERSION` 77 | p = path.join(this.workspaceRoot, '.python-version'); 78 | if (fs.existsSync(p)) { 79 | if (!process.env.PYENV_VERSION) { 80 | // pyenv local can special multiple Python, use first one only 81 | process.env.PYENV_VERSION = fs.readFileSync(p).toString().trim().split('\n')[0]; 82 | } 83 | return; 84 | } 85 | 86 | // pipenv 87 | p = path.join(this.workspaceRoot, 'Pipfile'); 88 | if (fs.existsSync(p)) { 89 | return child_process.spawnSync('pipenv', ['--py'], { encoding: 'utf8' }).stdout.trim(); 90 | } 91 | 92 | // poetry 93 | p = path.join(this.workspaceRoot, 'poetry.lock'); 94 | if (fs.existsSync(p)) { 95 | const list = child_process 96 | .spawnSync('poetry', ['env', 'list', '--full-path', '--no-ansi'], { 97 | encoding: 'utf8', 98 | cwd: this.workspaceRoot, 99 | }) 100 | .stdout.trim(); 101 | let info = ''; 102 | for (const item of list.split('\n')) { 103 | if (item.includes('(Activated)')) { 104 | info = item.replace(/\(Activated\)/, '').trim(); 105 | break; 106 | } 107 | info = item; 108 | } 109 | if (info) { 110 | return pythonBinFromPath(info); 111 | } 112 | } 113 | 114 | // pdm 115 | p = path.join(this.workspaceRoot, '.pdm-python'); 116 | if (fs.existsSync(p)) { 117 | return child_process.spawnSync('pdm', ['info', '--python'], { encoding: 'utf8' }).stdout.trim(); 118 | } 119 | 120 | // virtualenv in the workspace root 121 | const files = fs.readdirSync(this.workspaceRoot); 122 | for (const file of files) { 123 | const x = path.join(this.workspaceRoot, file); 124 | if (fs.existsSync(path.join(x, 'pyvenv.cfg'))) { 125 | return pythonBinFromPath(x); 126 | } 127 | } 128 | } catch (e) { 129 | console.error(e); 130 | } 131 | } 132 | 133 | protected update(pythonSettings: WorkspaceConfiguration) { 134 | const systemVariables: SystemVariables = new SystemVariables(this.workspaceRoot ? this.workspaceRoot : undefined); 135 | const vp = this.resolvePythonFromVENV(); 136 | this.pythonPath = vp ? vp : systemVariables.resolve(pythonSettings.get('pythonPath') as string); 137 | 138 | const lintingSettings = systemVariables.resolveAny(pythonSettings.get('linting'))!; 139 | if (this.linting) { 140 | Object.assign(this.linting, lintingSettings); 141 | } else { 142 | this.linting = lintingSettings; 143 | } 144 | this.linting.pylintPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.pylintPath)); 145 | this.linting.flake8Path = this.getAbsolutePath(systemVariables.resolveAny(this.linting.flake8Path)); 146 | this.linting.pycodestylePath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.pycodestylePath)); 147 | this.linting.pyflakesPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.pyflakesPath)); 148 | this.linting.pylamaPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.pylamaPath)); 149 | this.linting.prospectorPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.prospectorPath)); 150 | this.linting.pydocstylePath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.pydocstylePath)); 151 | this.linting.mypyPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.mypyPath)); 152 | this.linting.banditPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.banditPath)); 153 | this.linting.ruffPath = this.getAbsolutePath(systemVariables.resolveAny(this.linting.ruffPath)); 154 | 155 | const formattingSettings = systemVariables.resolveAny(pythonSettings.get('formatting'))!; 156 | if (this.formatting) { 157 | Object.assign(this.formatting, formattingSettings); 158 | } else { 159 | this.formatting = formattingSettings; 160 | } 161 | this.formatting.autopep8Path = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.autopep8Path)); 162 | this.formatting.yapfPath = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.yapfPath)); 163 | this.formatting.ruffPath = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.ruffPath)); 164 | this.formatting.blackPath = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.blackPath)); 165 | this.formatting.pyinkPath = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.pyinkPath)); 166 | this.formatting.blackdPath = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.blackdPath)); 167 | this.formatting.darkerPath = this.getAbsolutePath(systemVariables.resolveAny(this.formatting.darkerPath)); 168 | 169 | const isort = systemVariables.resolveAny(pythonSettings.get('sortImports'))!; 170 | if (this.sortImports) { 171 | Object.assign(this.sortImports, isort); 172 | } else { 173 | this.sortImports = isort; 174 | } 175 | this.sortImports.path = this.getAbsolutePath(systemVariables.resolveAny(this.sortImports.path)); 176 | } 177 | 178 | public get stdLibs(): string[] { 179 | return this._stdLibs; 180 | } 181 | 182 | public get pythonPath(): string { 183 | return this._pythonPath; 184 | } 185 | 186 | public set pythonPath(value: string) { 187 | if (this._pythonPath === value) { 188 | return; 189 | } 190 | try { 191 | this._pythonPath = getPythonExecutable(value); 192 | this._stdLibs = getStdLibs(this._pythonPath); 193 | } catch (_ex) { 194 | this._pythonPath = value; 195 | } 196 | } 197 | 198 | private getAbsolutePath(pathToCheck: string, rootDir?: string): string { 199 | const realPath = workspace.expand(pathToCheck); 200 | if (realPath.indexOf(path.sep) === -1) { 201 | return realPath; 202 | } 203 | const root = rootDir ? rootDir : this.workspaceRoot; 204 | return path.isAbsolute(realPath) ? realPath : path.resolve(root, realPath); 205 | } 206 | 207 | protected initialize(): void { 208 | this.disposables.push( 209 | workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { 210 | if (event.affectsConfiguration('python')) { 211 | const currentConfig = workspace.getConfiguration('python', workspace.root); 212 | this.update(currentConfig); 213 | } 214 | }), 215 | ); 216 | 217 | const initialConfig = workspace.getConfiguration('python', workspace.root); 218 | if (initialConfig) { 219 | this.update(initialConfig); 220 | } 221 | } 222 | } 223 | 224 | function getPythonExecutable(value: string): string { 225 | // If only 'python'. 226 | if (value === 'python' || value.indexOf(path.sep) === -1 || path.basename(value) === path.dirname(value)) { 227 | const bin = which.sync(value, { nothrow: true }); 228 | if (bin) { 229 | return bin; 230 | } 231 | } 232 | 233 | return workspace.expand(value); 234 | } 235 | 236 | function getStdLibs(pythonPath: string): string[] { 237 | try { 238 | let args = ['-c', 'import site;print(site.getsitepackages()[0])']; 239 | const sitePkgs = child_process.spawnSync(pythonPath, args, { encoding: 'utf8' }).stdout.trim(); 240 | 241 | args = ['-c', 'import site;print(site.getusersitepackages())']; 242 | const userPkgs = child_process.spawnSync(pythonPath, args, { encoding: 'utf8' }).stdout.trim(); 243 | 244 | return [sitePkgs, userPkgs]; 245 | } catch (_e) { 246 | return []; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/features/linters/lintingEngine.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { 5 | CancellationTokenSource, 6 | Diagnostic, 7 | type DiagnosticCollection, 8 | DiagnosticSeverity, 9 | type DocumentFilter, 10 | languages, 11 | type OutputChannel, 12 | Position, 13 | Range, 14 | type TextDocument, 15 | Uri, 16 | window, 17 | workspace, 18 | } from 'coc.nvim'; 19 | import fs from 'node:fs'; 20 | import { Minimatch } from 'minimatch'; 21 | import path from 'node:path'; 22 | import { PythonSettings } from '../../configSettings'; 23 | import { 24 | LintMessageSeverity, 25 | type ILinter, 26 | Product, 27 | type ILintMessage, 28 | type ILinterInfo, 29 | LinterErrors, 30 | } from '../../types'; 31 | import { Bandit } from './bandit'; 32 | import { Flake8 } from './flake8'; 33 | import { LinterInfo } from './linterInfo'; 34 | import { MyPy } from './mypy'; 35 | import { Prospector } from './prospector'; 36 | import { PyCodeStyle } from './pycodestyle'; 37 | import { PyDocStyle } from './pydocstyle'; 38 | import { Pyflakes } from './pyflakes'; 39 | import { Pylama } from './pylama'; 40 | import { Pylint } from './pylint'; 41 | import { Pytype } from './pytype'; 42 | import { Ruff } from './ruff'; 43 | 44 | const PYTHON: DocumentFilter = { language: 'python' }; 45 | 46 | const lintSeverityToVSSeverity = new Map(); 47 | lintSeverityToVSSeverity.set(LintMessageSeverity.Error, DiagnosticSeverity.Error); 48 | lintSeverityToVSSeverity.set(LintMessageSeverity.Hint, DiagnosticSeverity.Hint); 49 | lintSeverityToVSSeverity.set(LintMessageSeverity.Information, DiagnosticSeverity.Information); 50 | lintSeverityToVSSeverity.set(LintMessageSeverity.Warning, DiagnosticSeverity.Warning); 51 | 52 | class DisabledLinter implements ILinter { 53 | constructor(private configService: PythonSettings) {} 54 | public get info() { 55 | return new LinterInfo(Product.pylint, 'pylint', this.configService); 56 | } 57 | public async lint(): Promise { 58 | return []; 59 | } 60 | } 61 | 62 | export class LintingEngine { 63 | private diagnosticCollection: DiagnosticCollection; 64 | private pendingLintings = new Map(); 65 | private configService: PythonSettings; 66 | private outputChannel: OutputChannel; 67 | protected linters: ILinterInfo[]; 68 | 69 | constructor() { 70 | this.outputChannel = window.createOutputChannel('coc-pyright-linting'); 71 | this.diagnosticCollection = languages.createDiagnosticCollection('python'); 72 | this.configService = PythonSettings.getInstance(); 73 | this.linters = [ 74 | new LinterInfo(Product.bandit, 'bandit', this.configService), 75 | new LinterInfo(Product.flake8, 'flake8', this.configService), 76 | new LinterInfo(Product.pylint, 'pylint', this.configService, ['.pylintrc', 'pylintrc']), 77 | new LinterInfo(Product.mypy, 'mypy', this.configService), 78 | new LinterInfo(Product.pycodestyle, 'pycodestyle', this.configService), 79 | new LinterInfo(Product.prospector, 'prospector', this.configService), 80 | new LinterInfo(Product.pydocstyle, 'pydocstyle', this.configService), 81 | new LinterInfo(Product.pyflakes, 'pyflakes', this.configService), 82 | new LinterInfo(Product.pylama, 'pylama', this.configService), 83 | new LinterInfo(Product.pytype, 'pytype', this.configService), 84 | new LinterInfo(Product.ruff, 'ruff', this.configService), 85 | ]; 86 | } 87 | 88 | public get diagnostics(): DiagnosticCollection { 89 | return this.diagnosticCollection; 90 | } 91 | 92 | public clearDiagnostics(document: TextDocument): void { 93 | if (this.diagnosticCollection.has(document.uri)) { 94 | this.diagnosticCollection.delete(document.uri); 95 | } 96 | } 97 | 98 | public async lintOpenPythonFiles(): Promise { 99 | this.diagnosticCollection.clear(); 100 | const promises = workspace.textDocuments.map(async (document) => this.lintDocument(document)); 101 | await Promise.all(promises); 102 | return this.diagnosticCollection; 103 | } 104 | 105 | public async lintDocument(document: TextDocument, onChange = false): Promise { 106 | this.diagnosticCollection.set(document.uri, []); 107 | 108 | // Check if we need to lint this document 109 | if (!this.shouldLintDocument(document)) { 110 | return; 111 | } 112 | 113 | const fsPath = Uri.parse(document.uri).fsPath; 114 | if (this.pendingLintings.has(fsPath)) { 115 | this.pendingLintings.get(fsPath)!.cancel(); 116 | this.pendingLintings.delete(fsPath); 117 | } 118 | 119 | const cancelToken = new CancellationTokenSource(); 120 | cancelToken.token.onCancellationRequested(() => { 121 | if (this.pendingLintings.has(fsPath)) { 122 | this.pendingLintings.delete(fsPath); 123 | } 124 | }); 125 | 126 | this.pendingLintings.set(fsPath, cancelToken); 127 | 128 | const activeLinters = this.getActiveLinters().filter((l) => (onChange ? l.stdinSupport : true)); 129 | const promises: Promise[] = activeLinters.map(async (info: ILinterInfo) => { 130 | this.outputChannel.appendLine(`Using python from ${this.configService.pythonPath}\n`); 131 | this.outputChannel.appendLine(`${'#'.repeat(10)} active linter: ${info.id}`); 132 | const linter = await this.createLinter(info, this.outputChannel); 133 | const promise = linter.lint(document, cancelToken.token); 134 | return promise; 135 | }); 136 | 137 | // linters will resolve asynchronously - keep a track of all 138 | // diagnostics reported as them come in. 139 | let diagnostics: Diagnostic[] = []; 140 | const settings = this.configService; 141 | 142 | for (const p of promises) { 143 | const msgs = await p; 144 | if (cancelToken.token.isCancellationRequested) { 145 | break; 146 | } 147 | 148 | const doc = workspace.getDocument(document.uri); 149 | if (doc) { 150 | // Build the message and suffix the message with the name of the linter used. 151 | for (const m of msgs) { 152 | if ( 153 | doc 154 | .getline(m.line - 1) 155 | .trim() 156 | .startsWith('%') && 157 | (m.code === LinterErrors.pylint.InvalidSyntax || 158 | m.code === LinterErrors.prospector.InvalidSyntax || 159 | m.code === LinterErrors.flake8.InvalidSyntax) 160 | ) { 161 | continue; 162 | } 163 | diagnostics.push(this.createDiagnostics(m, document)); 164 | } 165 | // Limit the number of messages to the max value. 166 | diagnostics = diagnostics.filter((_value, index) => index <= settings.linting.maxNumberOfProblems); 167 | } 168 | } 169 | // Set all diagnostics found in this pass, as this method always clears existing diagnostics. 170 | this.diagnosticCollection.set(document.uri, diagnostics); 171 | } 172 | 173 | private createDiagnostics(message: ILintMessage, document: TextDocument): Diagnostic { 174 | let start = Position.create(message.line > 0 ? message.line - 1 : 0, message.column); 175 | const endLine = message.endLine ?? message.line; 176 | const endColumn = message.endColumn ?? message.column + 1; 177 | let end = Position.create(endLine > 0 ? endLine - 1 : 0, endColumn); 178 | 179 | const ms = /['"](.*?)['"]/g.exec(message.message); 180 | if (ms && ms.length > 0) { 181 | const line = workspace.getDocument(document.uri)?.getline(message.line - 1); 182 | if (line?.includes(ms[1])) { 183 | const s = message.column > line.indexOf(ms[1]) ? message.column : line.indexOf(ms[1]); 184 | start = Position.create(message.line - 1, s); 185 | end = Position.create(message.line - 1, s + ms[1].length); 186 | } 187 | } 188 | 189 | const range = Range.create(start, end); 190 | const severity = lintSeverityToVSSeverity.get(message.severity!)!; 191 | const diagnostic = Diagnostic.create(range, message.message, severity); 192 | diagnostic.code = message.code; 193 | if (message.url) { 194 | diagnostic.codeDescription = { href: message.url }; 195 | } 196 | diagnostic.source = message.provider; 197 | // @ts-expect-error 198 | diagnostic.fix = message.fix; 199 | diagnostic.tags = message.tags; 200 | return diagnostic; 201 | } 202 | 203 | private shouldLintDocument(document: TextDocument): boolean { 204 | const settings = this.configService; 205 | if (!settings.linting.enabled) { 206 | this.outputChannel.appendLine(`${'#'.repeat(5)} linting is disabled by python.linting.enabled`); 207 | return false; 208 | } 209 | 210 | if (document.languageId !== PYTHON.language) { 211 | return false; 212 | } 213 | 214 | const fsPath = Uri.parse(document.uri).fsPath; 215 | if (settings.stdLibs.some((p) => fsPath.startsWith(p))) { 216 | return false; 217 | } 218 | 219 | const relativeFileName = path.relative(workspace.root, fsPath); 220 | // { dot: true } is important so dirs like `.venv` will be matched by globs 221 | const ignoreMinmatches = settings.linting.ignorePatterns.map((pattern) => new Minimatch(pattern, { dot: true })); 222 | if (ignoreMinmatches.some((matcher) => matcher.match(fsPath) || matcher.match(relativeFileName))) { 223 | this.outputChannel.appendLine(`${'#'.repeat(5)} linting is ignored by python.linting.ignorePatterns`); 224 | return false; 225 | } 226 | 227 | const exists = fs.existsSync(fsPath); 228 | if (!exists) { 229 | this.outputChannel.appendLine(`${'#'.repeat(5)} linting is disabled because file is not exists: ${fsPath}`); 230 | return false; 231 | } 232 | return true; 233 | } 234 | 235 | public getAllLinterInfos(): ILinterInfo[] { 236 | return this.linters; 237 | } 238 | 239 | public getLinterInfo(product: Product): ILinterInfo { 240 | const x = this.linters.findIndex((value) => value.product === product); 241 | if (x >= 0) { 242 | return this.linters[x]; 243 | } 244 | throw new Error(`Invalid linter '${Product[product]}'`); 245 | } 246 | 247 | public getActiveLinters(resource?: Uri): ILinterInfo[] { 248 | return this.linters.filter((x) => x.isEnabled(resource)); 249 | } 250 | 251 | public async createLinter(info: ILinterInfo, outputChannel: OutputChannel): Promise { 252 | if (!this.configService.linting.enabled) { 253 | return new DisabledLinter(this.configService); 254 | } 255 | const error = 'Linter manager: Unknown linter'; 256 | switch (info.product) { 257 | case Product.bandit: 258 | return new Bandit(info, outputChannel); 259 | case Product.flake8: 260 | return new Flake8(info, outputChannel); 261 | case Product.pylint: 262 | return new Pylint(info, outputChannel); 263 | case Product.mypy: 264 | return new MyPy(info, outputChannel); 265 | case Product.prospector: 266 | return new Prospector(info, outputChannel); 267 | case Product.pylama: 268 | return new Pylama(info, outputChannel); 269 | case Product.pydocstyle: 270 | return new PyDocStyle(info, outputChannel); 271 | case Product.pycodestyle: 272 | return new PyCodeStyle(info, outputChannel); 273 | case Product.pytype: 274 | return new Pytype(info, outputChannel); 275 | case Product.pyflakes: 276 | return new Pyflakes(info, outputChannel); 277 | case Product.ruff: 278 | return new Ruff(info, outputChannel); 279 | default: 280 | break; 281 | } 282 | throw new Error(error); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coc-pyright 2 | 3 | 4 | 5 | GitHub Sponsors 6 | Patreon donate button 7 | PayPal donate button 8 | 9 | [Pyright](https://github.com/microsoft/pyright) extension for coc.nvim, with additional features: 10 | 11 | - semanticTokens highlighting! 12 | - inlayHints supports 13 | - codeActions to add imports, ignore typing check, run tests and more 14 | - linting with `bandit`, `flake8`, `mypy`, `ruff`, `prospector`, `pycodestyle`, `pydocstyle`, `pyflakes`, `pylama`, `pylint`, `pytype` 15 | - formatting with `ruff`, `yapf`, `black`, `autopep8`, `darker`, `blackd`, `pyink` 16 | - testing with `unittest` or `pytest`, supports codeLens 17 | - sort imports with `ruff`, `isort` and `pyright` 18 | - extract method and variables with `rope` 19 | 20 | 21 | image 22 | 23 | ## Install 24 | 25 | `:CocInstall coc-pyright` 26 | 27 | Note: Pyright may not work as expected if can't detect _project root_ correctly, check [#521](https://github.com/fannheyward/coc-pyright/issues/521#issuecomment-858530052) and [Using workspaceFolders](https://github.com/neoclide/coc.nvim/wiki/Using-workspaceFolders#resolve-workspace-folder) in coc.nvim. 28 | 29 | ## Commands 30 | 31 | - `python.runLinting`: Run linting 32 | - `python.sortImports`: Sort imports by `isort` or `ruff` 33 | - `pyright.version`: Show the currently used Pyright version 34 | - `pyright.organizeimports`: Organize imports by Pyright 35 | - `pyright.restartserver`: This command forces the type checker to discard all of its cached type information and restart analysis. It is useful in cases where new type stubs or libraries have been installed. 36 | - `pyright.createtypestub`: Creates Type Stubs with given module name, for example `:CocCommand pyright.createtypestub numpy` 37 | - `pyright.fileTest`: Run test for current test file 38 | - `pyright.singleTest`: Run test for single nearest test 39 | 40 | ## Configurations 41 | 42 | These configurations are used by `coc-pyright`, you need to set them in your `coc-settings.json`. 43 | 44 | | Configuration | Description | Default | 45 | | ------------------------------------------- | ------------------------------------------------------------------- | ------------- | 46 | | pyright.enable | Enable coc-pyright extension | true | 47 | | python.analysis.autoImportCompletions | Determines whether pyright offers auto-import completions | true | 48 | | python.analysis.autoSearchPaths | Automatically add common search paths like 'src' | true | 49 | | python.analysis.diagnosticMode | Analyzes and reports errors for open only or all files in workspace | openFilesOnly | 50 | | python.analysis.stubPath | Path to directory containing custom type stub files | typings | 51 | | python.analysis.typeshedPaths | Paths to look for typeshed modules | [] | 52 | | python.analysis.diagnosticSeverityOverrides | Override the severity levels for individual diagnostics | {} | 53 | | python.analysis.typeCheckingMode | Defines the default rule set for type checking | basic | 54 | | python.analysis.useLibraryCodeForTypes | Use library implementations to extract type information | true | 55 | | python.pythonPath | Path to Python | python | 56 | | python.venvPath | Path to folder with a list of Virtual Environments | "" | 57 | | python.formatting.provider | Provider for formatting | autopep8 | 58 | | python.formatting.blackPath | Custom path to black | black | 59 | | python.formatting.blackArgs | Arguments passed to black | [] | 60 | | python.formatting.darkerPath | Custom path to darker | darker | 61 | | python.formatting.darkerArgs | Arguments passed to darker | [] | 62 | | python.formatting.pyinkPath | Custom path to pyink | pyink | 63 | | python.formatting.pyinkArgs | Arguments passed to pyink | [] | 64 | | python.formatting.blackdPath | Custom path to blackd | blackd | 65 | | python.formatting.blackdHTTPURL | Custom blackd server url | "" | 66 | | python.formatting.blackdHTTPHeaders | Custom blackd request headers | {} | 67 | | python.formatting.yapfPath | Custom path to yapf | yapf | 68 | | python.formatting.yapfArgs | Arguments passed to yapf | [] | 69 | | python.formatting.autopep8Path | Custom path to autopep8 | autopep8 | 70 | | python.formatting.autopep8Args | Arguments passed to autopep8 | [] | 71 | | python.linting.enabled | Whether to lint Python files with external linters | true | 72 | | python.linting.flake8Enabled | Whether to lint with flake8 | false | 73 | | python.linting.banditEnabled | Whether to lint with bandit | false | 74 | | python.linting.mypyEnabled | Whether to lint with mypy | false | 75 | | python.linting.ruffEnabled | Whether to lint with ruff | false | 76 | | python.linting.pytypeEnabled | Whether to lint with pytype | false | 77 | | python.linting.pycodestyleEnabled | Whether to lint with pycodestyle | false | 78 | | python.linting.prospectorEnabled | Whether to lint with prospector | false | 79 | | python.linting.pydocstyleEnabled | Whether to lint with pydocstyle | false | 80 | | python.linting.pylamaEnabled | Whether to lint with pylama | false | 81 | | python.linting.pylintEnabled | Whether to lint with pylint | false | 82 | | python.linting.pyflakesEnabled | Whether to lint with pyflakes | false | 83 | | python.sortImports.path | Path to isort script, default using inner version | '' | 84 | | python.sortImports.args | Arguments passed to isort | [] | 85 | | pyright.server | Custom `pyright-langserver` path | '' | 86 | | pyright.disableCompletion | Disable completion from Pyright | false | 87 | | pyright.disableDiagnostics | Disable diagnostics from Pyright | false | 88 | | pyright.disableDocumentation | Disable hover documentation from Pyright | false | 89 | | pyright.disableProgressNotifications | Disable the initialization and workdone progress notifications | false | 90 | | pyright.completion.importSupport | Enable `python-import` completion source support | true | 91 | | pyright.completion.snippetSupport | Enable completion snippets support | true | 92 | | pyright.organizeimports.provider | Organize imports provider, `pyright`, `ruff` or `isort` | pyright | 93 | | pyright.inlayHints.functionReturnTypes | Enable inlay hints for function return types | true | 94 | | pyright.inlayHints.variableTypes | Enable inlay hints for variable types | true | 95 | | pyright.inlayHints.parameterTypes | Enable inlay hints for parameter types | true | 96 | | pyright.testing.provider | Provider for testing, supports `unittest` and `pytest` | unittest | 97 | | pyright.testing.unittestArgs | Arguments passed to unittest | [] | 98 | | pyright.testing.pytestArgs | Arguments passed to pytest | [] | 99 | 100 | Additional configuration options can be found in [package.json](./package.json). 101 | 102 | ## pyrightconfig.json 103 | 104 | Pyright supports [pyrightconfig.json](https://github.com/microsoft/pyright/blob/master/docs/configuration.md) that provide granular control over settings. 105 | 106 | ## Python typing and stub files 107 | 108 | To provide best experience, Pyright requires packages to be type annotated 109 | and/or have stub files. The Python community is currently in a transition phase 110 | where package authors are actively looking to provide that. Meanwhile, stub 111 | files for well-known packages may also be obtained from 3rd party, for example: 112 | 113 | - [Awesome Python Typing # stub-packages](https://github.com/typeddjango/awesome-python-typing#stub-packages) 114 | - [typeshed](https://github.com/python/typeshed) 115 | - [python-type-stubs](https://github.com/microsoft/python-type-stubs) 116 | 117 | ## Conda setup 118 | 119 | 1. Create the following file: 120 | 121 | ```sh 122 | #!/bin/bash 123 | python "$@" 124 | ``` 125 | 126 | 2. Make it executable: `chmod +x $path` 127 | 3. Set `python.pythonPath` in your `coc-settings.json`: `"python.pythonPath": ""` 128 | 4. Activate the environment before starting vim 129 | 130 | This way python from your currently activated environment will be used 131 | 132 | ## My Workflow with Pyright 133 | 134 | 1. create venv in project: `python3 -m venv .venv` 135 | 2. `source .venv/bin/activate` 136 | 3. install modules with pip and work with Pyright 137 | 4. `deactivate` 138 | 139 | ## License 140 | 141 | MIT 142 | 143 | --- 144 | 145 | > This extension is built with [create-coc-extension](https://github.com/fannheyward/create-coc-extension) 146 | -------------------------------------------------------------------------------- /src/features/refactor.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'node:child_process'; 2 | import { 3 | type Disposable, 4 | type Document, 5 | type OutputChannel, 6 | Position, 7 | type Range, 8 | type TextDocument, 9 | Uri, 10 | window, 11 | workspace, 12 | } from 'coc.nvim'; 13 | import * as path from 'node:path'; 14 | import fs from 'node:fs'; 15 | import { createDeferred, type Deferred } from '../async'; 16 | import { PythonSettings } from '../configSettings'; 17 | import { PythonExecutionService } from '../processService'; 18 | import type { IPythonSettings } from '../types'; 19 | import { 20 | getTextEditsFromPatch, 21 | getWindowsLineEndingCount, 22 | splitLines, 23 | getTempFileWithDocumentContents, 24 | } from '../utils'; 25 | 26 | class RefactorProxy implements Disposable { 27 | protected readonly isWindows = process.platform === 'win32'; 28 | private _process?: ChildProcess; 29 | private _startedSuccessfully = false; 30 | private _commandResolve?: (value?: any | PromiseLike) => void; 31 | private _commandReject!: (reason?: any) => void; 32 | private initialized!: Deferred; 33 | constructor( 34 | private extensionDir: string, 35 | readonly pythonSettings: IPythonSettings, 36 | private workspaceRoot: string, 37 | ) {} 38 | 39 | public dispose() { 40 | try { 41 | this._process!.kill(); 42 | } catch (_ex) {} 43 | this._process = undefined; 44 | } 45 | 46 | private getOffsetAt(document: TextDocument, position: Position): number { 47 | if (this.isWindows) { 48 | return document.offsetAt(position); 49 | } 50 | 51 | // get line count 52 | // Rope always uses LF, instead of CRLF on windows, funny isn't it 53 | // So for each line, reduce one character (for CR) 54 | // But Not all Windows users use CRLF 55 | const offset = document.offsetAt(position); 56 | const winEols = getWindowsLineEndingCount(document, offset); 57 | 58 | return offset - winEols; 59 | } 60 | 61 | public async addImport(document: TextDocument, filePath: string, name: string, parent: string): Promise { 62 | const options = await workspace.getFormatOptions(); 63 | const command = { 64 | lookup: 'add_import', 65 | id: '1', 66 | file: filePath, 67 | text: document.getText(), 68 | name, 69 | parent, 70 | indent_size: options.tabSize, 71 | }; 72 | return await this.sendCommand(JSON.stringify(command)); 73 | } 74 | 75 | public async extractVariable(document: TextDocument, name: string, filePath: string, range: Range): Promise { 76 | const options = await workspace.getFormatOptions(); 77 | const command = { 78 | lookup: 'extract_variable', 79 | file: filePath, 80 | start: this.getOffsetAt(document, range.start).toString(), 81 | end: this.getOffsetAt(document, range.end).toString(), 82 | id: '2', 83 | name, 84 | indent_size: options.tabSize, 85 | }; 86 | return await this.sendCommand(JSON.stringify(command)); 87 | } 88 | 89 | public async extractMethod(document: TextDocument, name: string, filePath: string, range: Range): Promise { 90 | const options = await workspace.getFormatOptions(); 91 | // Ensure last line is an empty line 92 | // if (!document.lineAt(document.lineCount - 1).isEmptyOrWhitespace && range.start.line === document.lineCount - 1) { 93 | // return Promise.reject('Missing blank line at the end of document (PEP8).') 94 | // } 95 | const command = { 96 | lookup: 'extract_method', 97 | file: filePath, 98 | start: this.getOffsetAt(document, range.start).toString(), 99 | end: this.getOffsetAt(document, range.end).toString(), 100 | id: '3', 101 | name, 102 | indent_size: options.tabSize, 103 | }; 104 | return await this.sendCommand(JSON.stringify(command)); 105 | } 106 | 107 | private async sendCommand(command: string): Promise { 108 | await this.initialize(); 109 | return await new Promise((resolve, reject) => { 110 | this._commandResolve = resolve; 111 | this._commandReject = reject; 112 | this._process!.stdin!.write(`${command}\n`); 113 | }); 114 | } 115 | 116 | private async initialize(): Promise { 117 | this.initialized = createDeferred(); 118 | const cwd = path.join(this.extensionDir, 'pythonFiles'); 119 | const args = ['refactor.py', this.workspaceRoot]; 120 | const pythonToolsExecutionService = new PythonExecutionService(); 121 | const result = pythonToolsExecutionService.execObservable(this.pythonSettings.pythonPath, args, { cwd }); 122 | this._process = result.proc; 123 | result.out.subscribe({ 124 | next: (output) => { 125 | if (output.source === 'stdout') { 126 | if (!this._startedSuccessfully && output.out.startsWith('STARTED')) { 127 | this._startedSuccessfully = true; 128 | return this.initialized.resolve(); 129 | } 130 | this.onData(output.out); 131 | } else { 132 | this.handleStdError(output.out); 133 | } 134 | }, 135 | error: (error) => this.handleError(error), 136 | }); 137 | 138 | return this.initialized.promise; 139 | } 140 | 141 | private handleStdError(data: string) { 142 | // Possible there was an exception in parsing the data returned 143 | // So append the data then parse it 144 | let errorResponse: { message: string; traceback: string; type: string }[]; 145 | try { 146 | errorResponse = data 147 | .split(/\r?\n/g) 148 | .filter((line) => line.length > 0) 149 | .map((resp) => JSON.parse(resp)); 150 | } catch (ex) { 151 | console.error(ex); 152 | // Possible we've only received part of the data, hence don't clear previousData 153 | return; 154 | } 155 | if (errorResponse[0].message.length === 0) { 156 | errorResponse[0].message = splitLines(errorResponse[0].traceback, { 157 | trim: false, 158 | removeEmptyEntries: false, 159 | }).pop()!; 160 | } 161 | const errorMessage = `${errorResponse[0].message}\n${errorResponse[0].traceback}`; 162 | 163 | if (this._startedSuccessfully) { 164 | this._commandReject(`Refactor failed. ${errorMessage}`); 165 | } else { 166 | if (errorResponse[0].type === 'ModuleNotFoundError') { 167 | this.initialized.reject('Not installed'); 168 | return; 169 | } 170 | 171 | this.initialized.reject(`Refactor failed. ${errorMessage}`); 172 | } 173 | } 174 | 175 | private handleError(error: Error) { 176 | if (this._startedSuccessfully) { 177 | return this._commandReject(error); 178 | } 179 | this.initialized.reject(error); 180 | } 181 | 182 | private onData(data: string) { 183 | if (!this._commandResolve) { 184 | return; 185 | } 186 | 187 | // Possible there was an exception in parsing the data returned 188 | // So append the data then parse it 189 | let response: any; 190 | try { 191 | response = data 192 | .split(/\r?\n/g) 193 | .filter((line) => line.length > 0) 194 | .map((resp) => JSON.parse(resp)); 195 | } catch (_ex) { 196 | // Possible we've only received part of the data, hence don't clear previousData 197 | return; 198 | } 199 | this.dispose(); 200 | this._commandResolve!(response[0]); 201 | this._commandResolve = undefined; 202 | } 203 | } 204 | 205 | interface RenameResponse { 206 | results: [{ diff: string }]; 207 | } 208 | 209 | function validateDocumentForRefactor(doc: Document): Promise { 210 | if (!doc.dirty) { 211 | return Promise.resolve(); 212 | } 213 | 214 | return new Promise((resolve, reject) => { 215 | workspace.nvim.command('write').then(() => { 216 | return resolve(); 217 | }, reject); 218 | }); 219 | } 220 | 221 | export async function extractVariable( 222 | root: string, 223 | document: TextDocument, 224 | range: Range, 225 | outputChannel: OutputChannel, 226 | ): Promise { 227 | const pythonToolsExecutionService = new PythonExecutionService(); 228 | const rope = await pythonToolsExecutionService.isModuleInstalled('rope'); 229 | if (!rope) { 230 | window.showWarningMessage('Module rope not installed'); 231 | return; 232 | } 233 | 234 | const doc = workspace.getDocument(document.uri); 235 | if (!doc) { 236 | return; 237 | } 238 | const tempFile = await getTempFileWithDocumentContents(document); 239 | const workspaceFolder = workspace.getWorkspaceFolder(doc.uri); 240 | const workspaceRoot = workspaceFolder ? Uri.parse(workspaceFolder.uri).fsPath : workspace.cwd; 241 | 242 | const pythonSettings = PythonSettings.getInstance(); 243 | return validateDocumentForRefactor(doc).then(() => { 244 | const newName = `newvariable${new Date().getMilliseconds().toString()}`; 245 | const proxy = new RefactorProxy(root, pythonSettings, workspaceRoot); 246 | const rename = proxy 247 | .extractVariable(doc.textDocument, newName, tempFile, range) 248 | .then((response) => { 249 | return response.results[0].diff; 250 | }); 251 | 252 | return extractName(doc, newName, rename, outputChannel, tempFile); 253 | }); 254 | } 255 | 256 | export async function extractMethod( 257 | root: string, 258 | document: TextDocument, 259 | range: Range, 260 | outputChannel: OutputChannel, 261 | ): Promise { 262 | const pythonToolsExecutionService = new PythonExecutionService(); 263 | const rope = await pythonToolsExecutionService.isModuleInstalled('rope'); 264 | if (!rope) { 265 | window.showWarningMessage('Module rope not installed'); 266 | return; 267 | } 268 | 269 | const doc = workspace.getDocument(document.uri); 270 | if (!doc) { 271 | return; 272 | } 273 | const tempFile = await getTempFileWithDocumentContents(document); 274 | const workspaceFolder = workspace.getWorkspaceFolder(doc.uri); 275 | const workspaceRoot = workspaceFolder ? Uri.parse(workspaceFolder.uri).fsPath : workspace.cwd; 276 | 277 | const pythonSettings = PythonSettings.getInstance(); 278 | return validateDocumentForRefactor(doc).then(() => { 279 | const newName = `newmethod${new Date().getMilliseconds().toString()}`; 280 | const proxy = new RefactorProxy(root, pythonSettings, workspaceRoot); 281 | const rename = proxy.extractMethod(doc.textDocument, newName, tempFile, range).then((response) => { 282 | return response.results[0].diff; 283 | }); 284 | 285 | return extractName(doc, newName, rename, outputChannel, tempFile); 286 | }); 287 | } 288 | 289 | async function extractName( 290 | textEditor: Document, 291 | newName: string, 292 | renameResponse: Promise, 293 | outputChannel: OutputChannel, 294 | tempFile: string, 295 | ): Promise { 296 | let changeStartsAtLine = -1; 297 | try { 298 | const diff = await renameResponse; 299 | if (diff.length === 0) { 300 | return []; 301 | } 302 | const edits = getTextEditsFromPatch(textEditor.getDocumentContent(), diff); 303 | for (const edit of edits) { 304 | if (changeStartsAtLine === -1 || changeStartsAtLine > edit.range.start.line) { 305 | changeStartsAtLine = edit.range.start.line; 306 | } 307 | } 308 | await textEditor.applyEdits(edits); 309 | await fs.promises.unlink(tempFile); 310 | 311 | if (changeStartsAtLine >= 0) { 312 | let newWordPosition: Position | undefined; 313 | for (let lineNumber = changeStartsAtLine; lineNumber < textEditor.lineCount; lineNumber += 1) { 314 | const line = textEditor.getline(lineNumber); 315 | const indexOfWord = line.indexOf(newName); 316 | if (indexOfWord >= 0) { 317 | newWordPosition = Position.create(lineNumber, indexOfWord); 318 | break; 319 | } 320 | } 321 | return workspace.jumpTo(textEditor.uri, newWordPosition).then(() => { 322 | return newWordPosition; 323 | }); 324 | } 325 | } catch (error) { 326 | let errorMessage = `${error}`; 327 | if (typeof error === 'string') { 328 | errorMessage = error; 329 | } 330 | if (error instanceof Error) { 331 | errorMessage = error.message; 332 | } 333 | outputChannel.appendLine(`${'#'.repeat(10)}Refactor Output${'#'.repeat(10)}`); 334 | outputChannel.appendLine(`Error in refactoring:\n${errorMessage}`); 335 | outputChannel.appendLine(''); 336 | window.showErrorMessage('Cannot perform refactoring using selected element(s).'); 337 | return await Promise.reject(error); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /pythonFiles/refactor.py: -------------------------------------------------------------------------------- 1 | # Arguments are: 2 | # 1. Working directory. 3 | # 2. Rope folder 4 | 5 | import difflib 6 | import io 7 | import json 8 | import os 9 | import sys 10 | import traceback 11 | 12 | try: 13 | import rope 14 | import rope.base.project 15 | import rope.base.taskhandle 16 | from rope.base import libutils 17 | from rope.refactor.rename import Rename 18 | from rope.refactor.extract import ExtractMethod, ExtractVariable 19 | from rope.refactor.importutils import FromImport, NormalImport 20 | from rope.refactor.importutils.module_imports import ModuleImports 21 | except ImportError: 22 | jsonMessage = { 23 | "error": True, 24 | "message": "Rope not installed", 25 | "traceback": "", 26 | "type": "ModuleNotFoundError", 27 | } 28 | sys.stderr.write(json.dumps(jsonMessage)) 29 | sys.stderr.flush() 30 | 31 | WORKSPACE_ROOT = sys.argv[1] 32 | ROPE_PROJECT_FOLDER = '.vim/.ropeproject' 33 | 34 | 35 | class RefactorProgress: 36 | """ 37 | Refactor progress information 38 | """ 39 | 40 | def __init__(self, name="Task Name", message=None, percent=0): 41 | self.name = name 42 | self.message = message 43 | self.percent = percent 44 | 45 | 46 | class ChangeType: 47 | """ 48 | Change Type Enum 49 | """ 50 | 51 | EDIT = 0 52 | NEW = 1 53 | DELETE = 2 54 | 55 | 56 | class Change: 57 | """""" 58 | 59 | EDIT = 0 60 | NEW = 1 61 | DELETE = 2 62 | 63 | def __init__(self, filePath, fileMode=ChangeType.EDIT, diff=""): 64 | self.filePath = filePath 65 | self.diff = diff 66 | self.fileMode = fileMode 67 | 68 | 69 | def x_diff(x): 70 | new = x["new_contents"] 71 | old = x["old_contents"] 72 | old_lines = old.splitlines(True) 73 | if not old_lines[-1].endswith("\n"): 74 | old_lines[-1] = old_lines[-1] + os.linesep 75 | new = new + os.linesep 76 | 77 | result = difflib.unified_diff( 78 | old_lines, 79 | new.splitlines(True), 80 | "a/" + x["path"], 81 | "b/" + x["path"], 82 | ) 83 | return "".join(list(result)) 84 | 85 | 86 | def get_diff(changeset): 87 | """This is a copy of the code form the ChangeSet.get_description method found in Rope.""" 88 | new = changeset.new_contents 89 | old = changeset.old_contents 90 | if old is None: 91 | if changeset.resource.exists(): 92 | old = changeset.resource.read() 93 | else: 94 | old = "" 95 | 96 | # Ensure code has a trailing empty lines, before generating a diff. 97 | # https://github.com/Microsoft/vscode-python/issues/695. 98 | old_lines = old.splitlines(True) 99 | if not old_lines[-1].endswith("\n"): 100 | old_lines[-1] = old_lines[-1] + os.linesep 101 | new = new + os.linesep 102 | 103 | result = difflib.unified_diff( 104 | old_lines, 105 | new.splitlines(True), 106 | "a/" + changeset.resource.path, 107 | "b/" + changeset.resource.path, 108 | ) 109 | return "".join(list(result)) 110 | 111 | 112 | class BaseRefactoring(object): 113 | """ 114 | Base class for refactorings 115 | """ 116 | 117 | def __init__(self, project, resource, name="Refactor", progressCallback=None): 118 | self._progressCallback = progressCallback 119 | self._handle = rope.base.taskhandle.TaskHandle(name) 120 | self._handle.add_observer(self._update_progress) 121 | self.project = project 122 | self.resource = resource 123 | self.changes = [] 124 | 125 | def _update_progress(self): 126 | jobset = self._handle.current_jobset() 127 | if jobset and not self._progressCallback is None: 128 | progress = RefactorProgress() 129 | # getting current job set name 130 | if jobset.get_name() is not None: 131 | progress.name = jobset.get_name() 132 | # getting active job name 133 | if jobset.get_active_job_name() is not None: 134 | progress.message = jobset.get_active_job_name() 135 | # adding done percent 136 | percent = jobset.get_percent_done() 137 | if percent is not None: 138 | progress.percent = percent 139 | if not self._progressCallback is None: 140 | self._progressCallback(progress) 141 | 142 | def stop(self): 143 | self._handle.stop() 144 | 145 | def refactor(self): 146 | try: 147 | self.onRefactor() 148 | except rope.base.exceptions.InterruptedTaskError: 149 | # we can ignore this exception, as user has cancelled refactoring 150 | pass 151 | 152 | def onRefactor(self): 153 | """ 154 | To be implemented by each base class 155 | """ 156 | pass 157 | 158 | 159 | class RenameRefactor(BaseRefactoring): 160 | def __init__( 161 | self, 162 | project, 163 | resource, 164 | name="Rename", 165 | progressCallback=None, 166 | startOffset=None, 167 | newName="new_Name", 168 | ): 169 | BaseRefactoring.__init__(self, project, resource, name, progressCallback) 170 | self._newName = newName 171 | self.startOffset = startOffset 172 | 173 | def onRefactor(self): 174 | renamed = Rename(self.project, self.resource, self.startOffset) 175 | changes = renamed.get_changes(self._newName, task_handle=self._handle) 176 | for item in changes.changes: 177 | if isinstance(item, rope.base.change.ChangeContents): 178 | self.changes.append( 179 | Change(item.resource.real_path, ChangeType.EDIT, get_diff(item)) 180 | ) 181 | else: 182 | raise Exception("Unknown Change") 183 | 184 | 185 | class ExtractVariableRefactor(BaseRefactoring): 186 | def __init__( 187 | self, 188 | project, 189 | resource, 190 | name="Extract Variable", 191 | progressCallback=None, 192 | startOffset=None, 193 | endOffset=None, 194 | newName="new_Name", 195 | similar=False, 196 | global_=False, 197 | ): 198 | BaseRefactoring.__init__(self, project, resource, name, progressCallback) 199 | self._newName = newName 200 | self._startOffset = startOffset 201 | self._endOffset = endOffset 202 | self._similar = similar 203 | self._global = global_ 204 | 205 | def onRefactor(self): 206 | renamed = ExtractVariable( 207 | self.project, self.resource, self._startOffset, self._endOffset 208 | ) 209 | changes = renamed.get_changes(self._newName, self._similar, self._global) 210 | for item in changes.changes: 211 | if isinstance(item, rope.base.change.ChangeContents): 212 | self.changes.append( 213 | Change(item.resource.real_path, ChangeType.EDIT, get_diff(item)) 214 | ) 215 | else: 216 | raise Exception("Unknown Change") 217 | 218 | 219 | class ExtractMethodRefactor(ExtractVariableRefactor): 220 | def __init__( 221 | self, 222 | project, 223 | resource, 224 | name="Extract Method", 225 | progressCallback=None, 226 | startOffset=None, 227 | endOffset=None, 228 | newName="new_Name", 229 | similar=False, 230 | global_=False, 231 | ): 232 | ExtractVariableRefactor.__init__( 233 | self, 234 | project, 235 | resource, 236 | name, 237 | progressCallback, 238 | startOffset=startOffset, 239 | endOffset=endOffset, 240 | newName=newName, 241 | similar=similar, 242 | global_=global_, 243 | ) 244 | 245 | def onRefactor(self): 246 | renamed = ExtractMethod( 247 | self.project, self.resource, self._startOffset, self._endOffset 248 | ) 249 | changes = renamed.get_changes(self._newName, self._similar, self._global) 250 | for item in changes.changes: 251 | if isinstance(item, rope.base.change.ChangeContents): 252 | self.changes.append( 253 | Change(item.resource.real_path, ChangeType.EDIT, get_diff(item)) 254 | ) 255 | else: 256 | raise Exception("Unknown Change") 257 | 258 | 259 | class ImportRefactor(BaseRefactoring): 260 | def __init__( 261 | self, 262 | project, 263 | resource, 264 | text = None, 265 | name = None, 266 | parent = None, 267 | ): 268 | BaseRefactoring.__init__(self, project, resource, name='Add Import', progressCallback=None) 269 | self._name = name 270 | self._text = text 271 | self._parent = parent 272 | 273 | def onRefactor(self): 274 | if self._parent: 275 | import_info = FromImport(self._parent, 0, [(self._name, None)]) 276 | else: 277 | import_info = NormalImport([(self._name, None)]) 278 | 279 | pymodule = self.project.get_pymodule(self.resource) 280 | module_imports = ModuleImports(self.project, pymodule) 281 | module_imports.add_import(import_info) 282 | changed_source = module_imports.get_changed_source() 283 | if changed_source: 284 | changeset = { 285 | "old_contents": self._text, 286 | "new_contents": changed_source, 287 | "path": self.resource.path 288 | } 289 | self.changes.append(Change(self.resource.path, ChangeType.EDIT, x_diff(changeset))) 290 | else: 291 | raise Exception('Unknown Change') 292 | 293 | 294 | class RopeRefactoring(object): 295 | def __init__(self): 296 | self.default_sys_path = sys.path 297 | self._input = io.open(sys.stdin.fileno(), encoding="utf-8") 298 | 299 | def _rename(self, filePath, start, newName, indent_size): 300 | """ 301 | Renames a variable 302 | """ 303 | project = rope.base.project.Project( 304 | WORKSPACE_ROOT, 305 | ropefolder=ROPE_PROJECT_FOLDER, 306 | save_history=False, 307 | indent_size=indent_size, 308 | ) 309 | resourceToRefactor = libutils.path_to_resource(project, filePath) 310 | refactor = RenameRefactor( 311 | project, resourceToRefactor, startOffset=start, newName=newName 312 | ) 313 | refactor.refactor() 314 | changes = refactor.changes 315 | project.close() 316 | valueToReturn = [] 317 | for change in changes: 318 | valueToReturn.append({"diff": change.diff}) 319 | return valueToReturn 320 | 321 | def _extractVariable(self, filePath, start, end, newName, indent_size): 322 | """ 323 | Extracts a variable 324 | """ 325 | project = rope.base.project.Project( 326 | WORKSPACE_ROOT, 327 | ropefolder=ROPE_PROJECT_FOLDER, 328 | save_history=False, 329 | indent_size=indent_size, 330 | ) 331 | resourceToRefactor = libutils.path_to_resource(project, filePath) 332 | refactor = ExtractVariableRefactor( 333 | project, 334 | resourceToRefactor, 335 | startOffset=start, 336 | endOffset=end, 337 | newName=newName, 338 | similar=True, 339 | ) 340 | refactor.refactor() 341 | changes = refactor.changes 342 | project.close() 343 | valueToReturn = [] 344 | for change in changes: 345 | valueToReturn.append({"diff": change.diff}) 346 | return valueToReturn 347 | 348 | def _extractMethod(self, filePath, start, end, newName, indent_size): 349 | """ 350 | Extracts a method 351 | """ 352 | project = rope.base.project.Project( 353 | WORKSPACE_ROOT, 354 | ropefolder=ROPE_PROJECT_FOLDER, 355 | save_history=False, 356 | indent_size=indent_size, 357 | ) 358 | resourceToRefactor = libutils.path_to_resource(project, filePath) 359 | refactor = ExtractMethodRefactor( 360 | project, 361 | resourceToRefactor, 362 | startOffset=start, 363 | endOffset=end, 364 | newName=newName, 365 | similar=True, 366 | ) 367 | refactor.refactor() 368 | changes = refactor.changes 369 | project.close() 370 | valueToReturn = [] 371 | for change in changes: 372 | valueToReturn.append({"diff": change.diff}) 373 | return valueToReturn 374 | 375 | def _add_import(self, filePath, text, name, parent, indent_size): 376 | """ 377 | Add import 378 | """ 379 | project = rope.base.project.Project( 380 | WORKSPACE_ROOT, 381 | ropefolder=ROPE_PROJECT_FOLDER, 382 | save_history=False, 383 | indent_size=indent_size, 384 | ) 385 | resourceToRefactor = libutils.path_to_resource(project, filePath) 386 | refactor = ImportRefactor( 387 | project, 388 | resourceToRefactor, 389 | text, 390 | name, 391 | parent 392 | ) 393 | refactor.refactor() 394 | changes = refactor.changes 395 | project.close() 396 | valueToReturn = [] 397 | for change in changes: 398 | valueToReturn.append({"diff": change.diff}) 399 | return valueToReturn 400 | 401 | def _serialize(self, identifier, results): 402 | """ 403 | Serializes the refactor results 404 | """ 405 | return json.dumps({"id": identifier, "results": results}) 406 | 407 | def _deserialize(self, request): 408 | """Deserialize request from VSCode. 409 | 410 | Args: 411 | request: String with raw request from VSCode. 412 | 413 | Returns: 414 | Python dictionary with request data. 415 | """ 416 | return json.loads(request) 417 | 418 | def _process_request(self, request): 419 | """Accept serialized request from VSCode and write response.""" 420 | request = self._deserialize(request) 421 | lookup = request.get("lookup", "") 422 | 423 | if lookup == "": 424 | pass 425 | elif lookup == "rename": 426 | changes = self._rename( 427 | request["file"], 428 | int(request["start"]), 429 | request["name"], 430 | int(request["indent_size"]), 431 | ) 432 | return self._write_response(self._serialize(request["id"], changes)) 433 | elif lookup == "add_import": 434 | changes = self._add_import( 435 | request["file"], 436 | request["text"], 437 | request["name"], 438 | request.get("parent", None), 439 | int(request["indent_size"]), 440 | ) 441 | return self._write_response(self._serialize(request["id"], changes)) 442 | elif lookup == "extract_variable": 443 | changes = self._extractVariable( 444 | request["file"], 445 | int(request["start"]), 446 | int(request["end"]), 447 | request["name"], 448 | int(request["indent_size"]), 449 | ) 450 | return self._write_response(self._serialize(request["id"], changes)) 451 | elif lookup == "extract_method": 452 | changes = self._extractMethod( 453 | request["file"], 454 | int(request["start"]), 455 | int(request["end"]), 456 | request["name"], 457 | int(request["indent_size"]), 458 | ) 459 | return self._write_response(self._serialize(request["id"], changes)) 460 | 461 | def _write_response(self, response): 462 | sys.stdout.write(response + "\n") 463 | sys.stdout.flush() 464 | 465 | def watch(self): 466 | self._write_response("STARTED") 467 | while True: 468 | try: 469 | self._process_request(self._input.readline()) 470 | except: 471 | exc_type, exc_value, exc_tb = sys.exc_info() 472 | tb_info = traceback.extract_tb(exc_tb) 473 | jsonMessage = { 474 | "error": True, 475 | "message": str(exc_value), 476 | "traceback": str(tb_info), 477 | "type": str(exc_type), 478 | } 479 | sys.stderr.write(json.dumps(jsonMessage)) 480 | sys.stderr.flush() 481 | 482 | 483 | if __name__ == "__main__": 484 | RopeRefactoring().watch() 485 | --------------------------------------------------------------------------------