Coverage for src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py: 18%

126 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-07-18 20:51 +0000

1import asyncio 

2import os.path 

3import random 

4import textwrap 

5from argparse import BooleanOptionalAction 

6from typing import TYPE_CHECKING 

7 

8from debputy.commands.debputy_cmd.context import ROOT_COMMAND, CommandContext, add_arg 

9from debputy.lsp.lsp_reference_keyword import ALL_PUBLIC_NAMED_STYLES 

10from debputy.util import _error, _info 

11 

12if TYPE_CHECKING: 

13 from debputy.lsp.debputy_ls import DebputyLanguageServer 

14 

15 

16_EDITOR_SNIPPETS = { 

17 "emacs": "emacs+eglot", 

18 "emacs+eglot": textwrap.dedent( 

19 """\ 

20 ;; `deputy lsp server` glue for emacs eglot (eglot is built-in these days) 

21 ;; 

22 ;; Add to ~/.emacs or ~/.emacs.d/init.el and then activate via `M-x eglot`. 

23 ;; 

24 ;; Requires: apt install elpa-dpkg-dev-el elpa-yaml-mode 

25 ;; Recommends: apt install elpa-markdown-mode 

26 

27 ;; Make emacs recognize debian/debputy.manifest as a YAML file 

28 (add-to-list 'auto-mode-alist '("/debian/debputy.manifest\\'" . yaml-mode)) 

29 ;; Inform eglot about the debputy LSP 

30 (with-eval-after-load 'eglot 

31 (add-to-list 'eglot-server-programs 

32 '( 

33 ( 

34 ;; Requires elpa-dpkg-dev-el (>= 37.12) 

35 (debian-autopkgtest-control-mode :language-id "debian/tests/control") 

36 ;; Requires elpa-dpkg-dev-el 

37 (debian-control-mode :language-id "debian/control") 

38 (debian-changelog-mode :language-id "debian/changelog") 

39 (debian-copyright-mode :language-id "debian/copyright") 

40 ;; No language id for these atm. 

41 makefile-gmake-mode 

42 ;; Requires elpa-yaml-mode 

43 yaml-mode 

44 ) 

45 . ("debputy" "lsp" "server") 

46 ))) 

47 

48 ;; Auto-start eglot for the relevant modes. 

49 (add-hook 'debian-control-mode-hook 'eglot-ensure) 

50 ;; Requires elpa-dpkg-dev-el (>= 37.12) 

51 ;; Technically, the `eglot-ensure` works before then, but it causes a 

52 ;; visible and very annoying long delay on opening the first changelog. 

53 ;; It still has a minor delay in 37.12, which may still be too long for 

54 ;; for your preference. In that case, comment it out. 

55 (add-hook 'debian-changelog-mode-hook 'eglot-ensure) 

56 (add-hook 'debian-copyright-mode-hook 'eglot-ensure) 

57 ;; Requires elpa-dpkg-dev-el (>= 37.12) 

58 (add-hook 'debian-autopkgtest-control-mode-hook 'eglot-ensure) 

59 (add-hook 'makefile-gmake-mode-hook 'eglot-ensure) 

60 (add-hook 'yaml-mode-hook 'eglot-ensure) 

61 """ 

62 ), 

63 "vim": "vim+youcompleteme", 

64 "vim+youcompleteme": textwrap.dedent( 

65 """\ 

66 # debputy lsp server glue for vim with vim-youcompleteme. Add to ~/.vimrc 

67 # 

68 # Requires: apt install vim-youcompleteme 

69 # - Your vim **MUST** be a provider of `vim-python3` (`vim-nox`/`vim-gtk`, etc.) 

70 

71 # Make vim recognize debputy.manifest as YAML file 

72 au BufNewFile,BufRead debputy.manifest setf yaml 

73 # Inform vim/ycm about the debputy LSP 

74 # - NB: No known support for debian/tests/control that we can hook into. 

75 # Feel free to provide one :) 

76 let g:ycm_language_server = [ 

77 \\ { 'name': 'debputy', 

78 \\ 'filetypes': [ 'debcontrol', 'debcopyright', 'debchangelog', 'autopkgtest', 'make', 'yaml'], 

79 \\ 'cmdline': [ 'debputy', 'lsp', 'server', '--ignore-language-ids' ] 

80 \\ }, 

81 \\ ] 

82 

83 packadd! youcompleteme 

84 # Add relevant ycm keybinding such as: 

85 # nmap <leader>d <plug>(YCMHover) 

86 """ 

87 ), 

88 "vim+vim9lsp": textwrap.dedent( 

89 """\ 

90 # debputy lsp server glue for vim with vim9 lsp. Add to ~/.vimrc 

91 # 

92 # Requires https://github.com/yegappan/lsp to be in your packages path 

93 

94 vim9script 

95 

96 # Make vim recognize debputy.manifest as YAML file 

97 autocmd BufNewFile,BufRead debputy.manifest setfiletype yaml 

98 

99 packadd! lsp 

100 

101 final lspServers: list<dict<any>> = [] 

102 

103 if executable('debputy') 

104 lspServers->add({ 

105 filetype: ['debcontrol', 'debcopyright', 'debchangelog', 'autopkgtest', 'make', 'yaml'], 

106 path: 'debputy', 

107 args: ['lsp', 'server', '--ignore-language-ids'] 

108 }) 

109 endif 

110 

111 autocmd User LspSetup g:LspOptionsSet({semanticHighlight: true}) 

112 autocmd User LspSetup g:LspAddServer(lspServers) 

113 """ 

114 ), 

115 "neovim": "neovim+nvim-lspconfig", 

116 "neovim+nvim-lspconfig": textwrap.dedent( 

117 """\ 

118 # debputy lsp server glue for neovim with nvim-lspconfig. Add to ~/.config/nvim/init.lua 

119 # 

120 # Requires https://github.com/neovim/nvim-lspconfig to be in your packages path 

121 

122 require("lspconfig").debputy.setup {capabilities = capabilities} 

123 

124 # Make vim recognize debputy.manifest as YAML file 

125 vim.filetype.add({ filename = {["debputy.manifest"] = "yaml"} }) 

126 """ 

127 ), 

128} 

129 

130 

131lsp_command = ROOT_COMMAND.add_dispatching_subcommand( 

132 "lsp", 

133 dest="lsp_command", 

134 help_description="Language server related subcommands", 

135) 

136 

137lsp_internal_commands = lsp_command.add_dispatching_subcommand( 

138 "internal-command", 

139 dest="internal_command", 

140 metavar="command", 

141 help_description="Commands used for internal purposes. These are implementation details and subject to change", 

142) 

143 

144 

145@lsp_command.register_subcommand( 

146 "server", 

147 log_only_to_stderr=True, 

148 help_description="Start the language server", 

149 argparser=[ 

150 add_arg( 

151 "--tcp", 

152 action="store_true", 

153 help="Use TCP server", 

154 ), 

155 add_arg( 

156 "--ws", 

157 action="store_true", 

158 help="Use WebSocket server", 

159 ), 

160 add_arg( 

161 "--host", 

162 default="127.0.0.1", 

163 help="Bind to this address (Use with --tcp / --ws)", 

164 ), 

165 add_arg( 

166 "--port", 

167 type=int, 

168 default=2087, 

169 help="Bind to this port (Use with --tcp / --ws)", 

170 ), 

171 add_arg( 

172 "--ignore-language-ids", 

173 dest="trust_language_ids", 

174 default=True, 

175 action="store_false", 

176 help="Disregard language IDs from the editor (rely solely on filename instead)", 

177 ), 

178 add_arg( 

179 "--force-locale", 

180 dest="forced_locale", 

181 default=None, 

182 action="store", 

183 help="Disregard locale from editor and always use provided locale code (translations)", 

184 ), 

185 ], 

186) 

187def lsp_server_cmd(context: CommandContext) -> None: 

188 parsed_args = context.parsed_args 

189 

190 feature_set = context.load_plugins() 

191 

192 from debputy.lsp.lsp_self_check import assert_can_start_lsp 

193 

194 assert_can_start_lsp() 

195 

196 from debputy.lsp.lsp_features import ( 

197 ensure_lsp_features_are_loaded, 

198 ) 

199 from debputy.lsp.lsp_dispatch import DEBPUTY_LANGUAGE_SERVER 

200 

201 ensure_lsp_features_are_loaded() 

202 debputy_language_server = DEBPUTY_LANGUAGE_SERVER 

203 debputy_language_server.plugin_feature_set = feature_set 

204 debputy_language_server.dctrl_parser = context.dctrl_parser 

205 debputy_language_server.trust_language_ids = parsed_args.trust_language_ids 

206 debputy_language_server.forced_locale = parsed_args.forced_locale 

207 

208 debputy_language_server.finish_startup_initialization() 

209 

210 if parsed_args.tcp and parsed_args.ws: 

211 _error("Sorry, --tcp and --ws are mutually exclusive") 

212 

213 if parsed_args.tcp: 

214 debputy_language_server.start_tcp(parsed_args.host, parsed_args.port) 

215 elif parsed_args.ws: 

216 debputy_language_server.start_ws(parsed_args.host, parsed_args.port) 

217 else: 

218 debputy_language_server.start_io() 

219 

220 

221@lsp_command.register_subcommand( 

222 "editor-config", 

223 help_description="Provide editor configuration snippets", 

224 argparser=[ 

225 add_arg( 

226 "editor_name", 

227 metavar="editor", 

228 choices=_EDITOR_SNIPPETS, 

229 default=None, 

230 nargs="?", 

231 help="The editor to provide a snippet for", 

232 ), 

233 ], 

234) 

235def lsp_editor_glue(context: CommandContext) -> None: 

236 editor_name = context.parsed_args.editor_name 

237 

238 if editor_name is None: 

239 content = [] 

240 for editor_name, payload in _EDITOR_SNIPPETS.items(): 

241 alias_of = "" 

242 if payload in _EDITOR_SNIPPETS: 

243 alias_of = f" (short for: {payload})" 

244 content.append((editor_name, alias_of)) 

245 max_name = max(len(c[0]) for c in content) 

246 print( 

247 "This version of debputy has instructions or editor config snippets for the following editors: " 

248 ) 

249 print() 

250 for editor_name, alias_of in content: 

251 print(f" * {editor_name:<{max_name}}{alias_of}") 

252 print() 

253 choice = random.Random().choice(list(_EDITOR_SNIPPETS)) 

254 print( 

255 f"Use `debputy lsp editor-config {choice}` (as an example) to see the instructions for a concrete editor." 

256 ) 

257 return 

258 result = _EDITOR_SNIPPETS[editor_name] 

259 while result in _EDITOR_SNIPPETS: 

260 result = _EDITOR_SNIPPETS[result] 

261 print(result) 

262 

263 

264@lsp_command.register_subcommand( 

265 "features", 

266 help_description="Describe language ids and features", 

267) 

268def lsp_describe_features(context: CommandContext) -> None: 

269 

270 from debputy.lsp.lsp_self_check import assert_can_start_lsp 

271 

272 try: 

273 from debputy.lsp.lsp_features import describe_lsp_features 

274 except ImportError: 

275 assert_can_start_lsp() 

276 raise AssertionError( 

277 "Cannot load the language server features but `assert_can_start_lsp` did not fail" 

278 ) 

279 

280 describe_lsp_features(context) 

281 

282 

283@ROOT_COMMAND.register_subcommand( 

284 "lint", 

285 log_only_to_stderr=True, 

286 help_description="Provide diagnostics for the packaging (like `lsp server` except no editor is needed)", 

287 argparser=[ 

288 add_arg( 

289 "--spellcheck", 

290 dest="spellcheck", 

291 action="store_true", 

292 help="Enable spellchecking", 

293 ), 

294 add_arg( 

295 "--auto-fix", 

296 dest="auto_fix", 

297 action="store_true", 

298 help="Automatically fix problems with trivial or obvious corrections.", 

299 ), 

300 add_arg( 

301 "--linter-exit-code", 

302 dest="linter_exit_code", 

303 default=True, 

304 action=BooleanOptionalAction, 

305 help='Enable or disable the "linter" convention of exiting with an error if severe issues were found', 

306 ), 

307 add_arg( 

308 "--lint-report-format", 

309 dest="lint_report_format", 

310 default="term", 

311 choices=["term", "junit4-xml"], 

312 help="The report output format", 

313 ), 

314 add_arg( 

315 "--report-output", 

316 dest="report_output", 

317 default=None, 

318 action="store", 

319 help="Where to place the report (for report formats that generate files/directory reports)", 

320 ), 

321 add_arg( 

322 "--warn-about-check-manifest", 

323 dest="warn_about_check_manifest", 

324 default=True, 

325 action=BooleanOptionalAction, 

326 help="Warn about limitations that check-manifest would cover if d/debputy.manifest is present", 

327 ), 

328 ], 

329) 

330def lint_cmd(context: CommandContext) -> None: 

331 try: 

332 import lsprotocol 

333 except ImportError: 

334 _error("This feature requires lsprotocol (apt-get install python3-lsprotocol)") 

335 

336 from debputy.linting.lint_impl import perform_linting 

337 

338 context.must_be_called_in_source_root() 

339 asyncio.run(perform_linting(context)) 

340 

341 

342@ROOT_COMMAND.register_subcommand( 

343 "reformat", 

344 help_description="Reformat the packaging files based on the packaging/maintainer rules", 

345 argparser=[ 

346 add_arg( 

347 "--style", 

348 dest="named_style", 

349 choices=ALL_PUBLIC_NAMED_STYLES, 

350 default=None, 

351 help="The formatting style to use (overrides packaging style).", 

352 ), 

353 add_arg( 

354 "--auto-fix", 

355 dest="auto_fix", 

356 default=True, 

357 action=BooleanOptionalAction, 

358 help="Whether to automatically apply any style changes.", 

359 ), 

360 add_arg( 

361 "--linter-exit-code", 

362 dest="linter_exit_code", 

363 default=True, 

364 action=BooleanOptionalAction, 

365 help='Enable or disable the "linter" convention of exiting with an error if issues were found', 

366 ), 

367 add_arg( 

368 "--supported-style-is-required", 

369 dest="supported_style_required", 

370 default=True, 

371 action="store_true", 

372 help="Fail with an error if a supported style cannot be identified.", 

373 ), 

374 add_arg( 

375 "--unknown-or-unsupported-style-is-ok", 

376 dest="supported_style_required", 

377 action="store_false", 

378 help="Do not exit with an error if no supported style can be identified. Useful for general" 

379 ' pipelines to implement "reformat if possible"', 

380 ), 

381 add_arg( 

382 "--missing-style-is-ok", 

383 dest="supported_style_required", 

384 action="store_false", 

385 help="[Deprecated] Use --unknown-or-unsupported-style-is-ok instead", 

386 ), 

387 ], 

388) 

389def reformat_cmd(context: CommandContext) -> None: 

390 try: 

391 import lsprotocol 

392 except ImportError: 

393 _error("This feature requires lsprotocol (apt-get install python3-lsprotocol)") 

394 

395 from debputy.linting.lint_impl import perform_reformat 

396 

397 context.must_be_called_in_source_root() 

398 perform_reformat(context, named_style=context.parsed_args.named_style) 

399 

400 

401@lsp_internal_commands.register_subcommand( 

402 "semantic-tokens", 

403 log_only_to_stderr=True, 

404 help_description="Test semantic tokens", 

405 argparser=[ 

406 add_arg( 

407 "filename", 

408 help="Filename to annotate", 

409 ), 

410 add_arg( 

411 "--language-id", 

412 action="store", 

413 type=str, 

414 default="", 

415 help="Choose the language id", 

416 ), 

417 ], 

418) 

419def lsp_test_semantic_tokens_cmd(context: CommandContext) -> None: 

420 parsed_args = context.parsed_args 

421 filename = parsed_args.filename 

422 

423 debputy_ls = _load_test_lsp(context) 

424 

425 with open(filename) as fd: 

426 data = fd.read() 

427 

428 doc_uri = f"file://{os.path.abspath(filename)}" 

429 

430 _fake_initialize( 

431 debputy_ls, 

432 doc_uri, 

433 data, 

434 language_id=parsed_args.language_id, 

435 ) 

436 

437 from debputy.lsp.lsp_dispatch import semantic_tokens_full 

438 from debputy.lsp.lsp_test_support import resolve_semantic_tokens 

439 import lsprotocol.types as types 

440 import asyncio 

441 

442 result = asyncio.get_event_loop().run_until_complete( 

443 semantic_tokens_full( 

444 debputy_ls, 

445 types.SemanticTokensParams(types.TextDocumentIdentifier(doc_uri)), 

446 ) 

447 ) 

448 

449 if result is None: 

450 _info("No semantic were tokens provided") 

451 return 

452 

453 lines = data.splitlines(keepends=True) 

454 tokens = resolve_semantic_tokens( 

455 lines, 

456 result, 

457 ) 

458 _info(f"Identified {len(tokens)}") 

459 for token in tokens: 

460 print(f"{filename}:{token.range}: {token.token_name} {token.value!r}") 

461 

462 

463def _load_test_lsp( 

464 context: CommandContext, 

465) -> "DebputyLanguageServer": 

466 from debputy.lsp.lsp_self_check import assert_can_start_lsp 

467 

468 assert_can_start_lsp() 

469 

470 from debputy.lsp.lsp_features import ( 

471 ensure_lsp_features_are_loaded, 

472 ) 

473 from debputy.lsp.lsp_dispatch import DEBPUTY_LANGUAGE_SERVER 

474 

475 ensure_lsp_features_are_loaded() 

476 debputy_language_server = DEBPUTY_LANGUAGE_SERVER 

477 debputy_language_server.plugin_feature_set = context.load_plugins() 

478 debputy_language_server.dctrl_parser = context.dctrl_parser 

479 debputy_language_server.trust_language_ids = True 

480 debputy_language_server.forced_locale = None 

481 

482 debputy_language_server.finish_startup_initialization() 

483 

484 return debputy_language_server 

485 

486 

487def _fake_initialize( 

488 debputy_ls: "DebputyLanguageServer", 

489 doc_uri: str, 

490 text: str, 

491 *, 

492 language_id: str = "", 

493 version: int = 1, 

494) -> None: 

495 import lsprotocol.types as types 

496 

497 debputy_ls.lsp.lsp_initialize( 

498 types.InitializeParams( 

499 types.ClientCapabilities( 

500 general=types.GeneralClientCapabilities( 

501 position_encodings=[types.PositionEncodingKind.Utf32], 

502 ) 

503 ) 

504 ) 

505 ) 

506 debputy_ls.workspace.put_text_document( 

507 types.TextDocumentItem(doc_uri, language_id, version, text) 

508 ) 

509 

510 

511def ensure_lint_and_lsp_commands_are_loaded() -> None: 

512 # Loading the module does the heavy lifting 

513 # However, having this function means that we do not have an "unused" import that some tool 

514 # gets tempted to remove 

515 assert ROOT_COMMAND.has_command("lsp") 

516 assert ROOT_COMMAND.has_command("lint") 

517 assert ROOT_COMMAND.has_command("reformat")