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

130 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-04-19 20:37 +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.named_styles 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 ;; `deputy lsp server` glue for emacs eglot (eglot is built-in these days) 

20 ;; 

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

22 ;; 

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

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

25 

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

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

28 ;; Inform eglot about the debputy LSP 

29 (with-eval-after-load 'eglot 

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

31 '( 

32 ( 

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

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

35 ;; Requires elpa-dpkg-dev-el 

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

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

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

39 ;; No language id for these atm. 

40 makefile-gmake-mode 

41 ;; Requires elpa-yaml-mode 

42 yaml-mode 

43 ) 

44 . ("debputy" "lsp" "server") 

45 ))) 

46 

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

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

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

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

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

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

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

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

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

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

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

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

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

60 """), 

61 "vim": "vim+youcompleteme", 

62 "vim+youcompleteme": textwrap.dedent("""\ 

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

64 # 

65 # Requires: apt install vim-youcompleteme 

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

67 

68 # Make vim recognize debputy.manifest as YAML file 

69 au BufNewFile,BufRead debputy.manifest setf yaml 

70 # Inform vim/ycm about the debputy LSP 

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

72 # Feel free to provide one :) 

73 let g:ycm_language_server = [ 

74 \\ { 'name': 'debputy', 

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

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

77 \\ }, 

78 \\ ] 

79 

80 packadd! youcompleteme 

81 # Add relevant ycm keybinding such as: 

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

83 """), 

84 "vim+vim9lsp": textwrap.dedent("""\ 

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

86 # 

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

88 

89 vim9script 

90 

91 # Make vim recognize debputy.manifest as YAML file 

92 autocmd BufNewFile,BufRead debputy.manifest setfiletype yaml 

93 

94 packadd! lsp 

95 

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

97 

98 if executable('debputy') 

99 lspServers->add({ 

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

101 path: 'debputy', 

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

103 }) 

104 endif 

105 

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

107 autocmd User LspSetup g:LspAddServer(lspServers) 

108 """), 

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

110 "neovim+nvim-lspconfig": textwrap.dedent("""\ 

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

112 # 

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

114 

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

116 

117 # Make vim recognize debputy.manifest as YAML file 

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

119 """), 

120} 

121 

122 

123lsp_command = ROOT_COMMAND.add_dispatching_subcommand( 

124 "lsp", 

125 dest="lsp_command", 

126 help_description="Language server related subcommands", 

127) 

128 

129lsp_internal_commands = lsp_command.add_dispatching_subcommand( 

130 "internal-command", 

131 dest="internal_command", 

132 metavar="command", 

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

134) 

135 

136 

137@lsp_command.register_subcommand( 

138 "server", 

139 log_only_to_stderr=True, 

140 help_description="Start the language server", 

141 argparser=[ 

142 add_arg( 

143 "--tcp", 

144 action="store_true", 

145 help="Use TCP server", 

146 ), 

147 add_arg( 

148 "--ws", 

149 action="store_true", 

150 help="Use WebSocket server", 

151 ), 

152 add_arg( 

153 "--host", 

154 default="127.0.0.1", 

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

156 ), 

157 add_arg( 

158 "--port", 

159 type=int, 

160 default=2087, 

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

162 ), 

163 add_arg( 

164 "--ignore-language-ids", 

165 dest="trust_language_ids", 

166 default=True, 

167 action="store_false", 

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

169 ), 

170 add_arg( 

171 "--force-locale", 

172 dest="forced_locale", 

173 default=None, 

174 action="store", 

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

176 ), 

177 ], 

178) 

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

180 parsed_args = context.parsed_args 

181 

182 feature_set = context.load_plugins() 

183 

184 from debputy.lsp.lsp_self_check import assert_can_start_lsp 

185 

186 assert_can_start_lsp() 

187 

188 from debputy.lsp.lsp_features import ( 

189 ensure_cli_lsp_features_are_loaded, 

190 ) 

191 from debputy.lsp.lsp_dispatch import DEBPUTY_LANGUAGE_SERVER 

192 

193 ensure_cli_lsp_features_are_loaded() 

194 debputy_language_server = DEBPUTY_LANGUAGE_SERVER 

195 debputy_language_server.plugin_feature_set = feature_set 

196 debputy_language_server.dctrl_parser = context.dctrl_parser 

197 debputy_language_server.trust_language_ids = parsed_args.trust_language_ids 

198 debputy_language_server.forced_locale = parsed_args.forced_locale 

199 

200 debputy_language_server.finish_startup_initialization() 

201 

202 if parsed_args.tcp and parsed_args.ws: 

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

204 

205 if parsed_args.tcp: 

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

207 elif parsed_args.ws: 

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

209 else: 

210 debputy_language_server.start_io() 

211 

212 

213@lsp_command.register_subcommand( 

214 "editor-config", 

215 help_description="Provide editor configuration snippets", 

216 argparser=[ 

217 add_arg( 

218 "editor_name", 

219 metavar="editor", 

220 choices=_EDITOR_SNIPPETS, 

221 default=None, 

222 nargs="?", 

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

224 ), 

225 ], 

226) 

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

228 editor_name = context.parsed_args.editor_name 

229 

230 if editor_name is None: 

231 content = [] 

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

233 alias_of = "" 

234 if payload in _EDITOR_SNIPPETS: 

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

236 content.append((editor_name, alias_of)) 

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

238 print( 

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

240 ) 

241 print() 

242 for editor_name, alias_of in content: 

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

244 print() 

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

246 print( 

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

248 ) 

249 return 

250 result = _EDITOR_SNIPPETS[editor_name] 

251 while result in _EDITOR_SNIPPETS: 

252 result = _EDITOR_SNIPPETS[result] 

253 print(result) 

254 

255 

256@lsp_command.register_subcommand( 

257 "features", 

258 help_description="Describe language ids and features", 

259) 

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

261 

262 from debputy.lsp.lsp_self_check import assert_can_start_lsp 

263 

264 try: 

265 from debputy.lsp.lsp_features import describe_lsp_features 

266 except ImportError: 

267 assert_can_start_lsp() 

268 raise AssertionError( 

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

270 ) 

271 

272 describe_lsp_features(context) 

273 

274 

275@ROOT_COMMAND.register_subcommand( 

276 "lint", 

277 log_only_to_stderr=True, 

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

279 argparser=[ 

280 add_arg( 

281 "--spellcheck", 

282 dest="spellcheck", 

283 action="store_true", 

284 help="Enable spellchecking", 

285 ), 

286 add_arg( 

287 "--auto-fix", 

288 dest="auto_fix", 

289 action="store_true", 

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

291 ), 

292 add_arg( 

293 "--linter-exit-code", 

294 dest="linter_exit_code", 

295 default=True, 

296 action=BooleanOptionalAction, 

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

298 ), 

299 add_arg( 

300 "--lint-report-format", 

301 dest="lint_report_format", 

302 default="term", 

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

304 help="The report output format", 

305 ), 

306 add_arg( 

307 "--report-output", 

308 dest="report_output", 

309 default=None, 

310 action="store", 

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

312 ), 

313 add_arg( 

314 "--warn-about-check-manifest", 

315 dest="warn_about_check_manifest", 

316 default=True, 

317 action=BooleanOptionalAction, 

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

319 ), 

320 ], 

321) 

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

323 try: 

324 import lsprotocol 

325 except ImportError: 

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

327 

328 from debputy.lsp.lsp_features import ( 

329 ensure_cli_lsp_features_are_loaded, 

330 ) 

331 from debputy.linting.lint_impl import perform_linting 

332 

333 context.must_be_called_in_source_root() 

334 ensure_cli_lsp_features_are_loaded() 

335 asyncio.run(perform_linting(context)) 

336 

337 

338@ROOT_COMMAND.register_subcommand( 

339 "reformat", 

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

341 argparser=[ 

342 add_arg( 

343 "--style", 

344 dest="named_style", 

345 choices=ALL_PUBLIC_NAMED_STYLES, 

346 default=None, 

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

348 ), 

349 add_arg( 

350 "--write-style", 

351 dest="write_style", 

352 action="store_true", 

353 default=False, 

354 help="Add the chosen style (from --style) into the reformatted debian/control.", 

355 ), 

356 add_arg( 

357 "--auto-fix", 

358 dest="auto_fix", 

359 default=True, 

360 action=BooleanOptionalAction, 

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

362 ), 

363 add_arg( 

364 "--linter-exit-code", 

365 dest="linter_exit_code", 

366 default=True, 

367 action=BooleanOptionalAction, 

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

369 ), 

370 add_arg( 

371 "--supported-style-is-required", 

372 dest="supported_style_required", 

373 default=True, 

374 action="store_true", 

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

376 ), 

377 add_arg( 

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

379 dest="supported_style_required", 

380 action="store_false", 

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

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

383 ), 

384 add_arg( 

385 "--missing-style-is-ok", 

386 dest="supported_style_required", 

387 action="store_false", 

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

389 ), 

390 ], 

391) 

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

393 try: 

394 import lsprotocol 

395 except ImportError: 

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

397 

398 from debputy.lsp.lsp_features import ( 

399 ensure_cli_lsp_features_are_loaded, 

400 ) 

401 from debputy.linting.lint_impl import perform_reformat 

402 

403 context.must_be_called_in_source_root() 

404 ensure_cli_lsp_features_are_loaded() 

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

406 

407 

408@lsp_internal_commands.register_subcommand( 

409 "semantic-tokens", 

410 log_only_to_stderr=True, 

411 help_description="Test semantic tokens", 

412 argparser=[ 

413 add_arg( 

414 "filename", 

415 help="Filename to annotate", 

416 ), 

417 add_arg( 

418 "--language-id", 

419 action="store", 

420 type=str, 

421 default="", 

422 help="Choose the language id", 

423 ), 

424 ], 

425) 

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

427 parsed_args = context.parsed_args 

428 filename = parsed_args.filename 

429 

430 debputy_ls = _load_test_lsp(context) 

431 

432 with open(filename) as fd: 

433 data = fd.read() 

434 

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

436 

437 _fake_initialize( 

438 debputy_ls, 

439 doc_uri, 

440 data, 

441 language_id=parsed_args.language_id, 

442 ) 

443 

444 from debputy.lsp.lsp_dispatch import semantic_tokens_full 

445 from debputy.lsp.lsp_test_support import resolve_semantic_tokens 

446 import lsprotocol.types as types 

447 import asyncio 

448 

449 result = asyncio.get_event_loop().run_until_complete( 

450 semantic_tokens_full( 

451 debputy_ls, 

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

453 ) 

454 ) 

455 

456 if result is None: 

457 _info("No semantic were tokens provided") 

458 return 

459 

460 lines = data.splitlines(keepends=True) 

461 tokens = resolve_semantic_tokens( 

462 lines, 

463 result, 

464 ) 

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

466 for token in tokens: 

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

468 

469 

470def _load_test_lsp( 

471 context: CommandContext, 

472) -> "DebputyLanguageServer": 

473 from debputy.lsp.lsp_self_check import assert_can_start_lsp 

474 

475 assert_can_start_lsp() 

476 

477 from debputy.lsp.lsp_features import ( 

478 ensure_cli_lsp_features_are_loaded, 

479 ) 

480 from debputy.lsp.lsp_dispatch import DEBPUTY_LANGUAGE_SERVER 

481 

482 ensure_cli_lsp_features_are_loaded() 

483 debputy_language_server = DEBPUTY_LANGUAGE_SERVER 

484 debputy_language_server.plugin_feature_set = context.load_plugins() 

485 debputy_language_server.dctrl_parser = context.dctrl_parser 

486 debputy_language_server.trust_language_ids = True 

487 debputy_language_server.forced_locale = None 

488 

489 debputy_language_server.finish_startup_initialization() 

490 

491 return debputy_language_server 

492 

493 

494def _fake_initialize( 

495 debputy_ls: "DebputyLanguageServer", 

496 doc_uri: str, 

497 text: str, 

498 *, 

499 language_id: str = "", 

500 version: int = 1, 

501) -> None: 

502 import lsprotocol.types as types 

503 

504 debputy_ls.lsp.lsp_initialize( 

505 types.InitializeParams( 

506 types.ClientCapabilities( 

507 general=types.GeneralClientCapabilities( 

508 position_encodings=[types.PositionEncodingKind.Utf32], 

509 ) 

510 ) 

511 ) 

512 ) 

513 debputy_ls.workspace.put_text_document( 

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

515 ) 

516 

517 

518def ensure_lint_and_lsp_commands_are_loaded() -> None: 

519 # Loading the module does the heavy lifting 

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

521 # gets tempted to remove 

522 assert ROOT_COMMAND.has_command("lsp") 

523 assert ROOT_COMMAND.has_command("lint") 

524 assert ROOT_COMMAND.has_command("reformat")