[docs] 1# @Test suite for CLI commands including analyse\, discover\, and write, TEST_CLI_1, test, [IMPL_CLI_ANALYZE, IMPL_CLI_DISCOVER, IMPL_CLI_WRITE]
2import json
3import re
4import tomllib
5from pathlib import Path
6
7import pytest
8import toml
9from typer.testing import CliRunner
10
11from sphinx_codelinks.cmd import WRITE_RST_DEPRECATED, app
12from sphinx_codelinks.source_discover.config import CommentType
13
14from .conftest import DATA_DIR, TEST_DIR
15
16ONELINE_COMMENT_TEMPLATE = {
17 "start_sequence": "[[",
18 "end_sequence": "]]",
19 "field_split_char": ",",
20 "needs_fields": [
21 {"name": "id"},
22 {"name": "title"},
23 {"name": "type"},
24 ],
25}
26SRC_DISCOVER_TEMPLATE = {
27 "src_dir": str(TEST_DIR / "data" / "dcdc"),
28 "exclude": ["**/charge/demo_1.cpp", "**/discharge/demo_3.cpp"],
29 "include": ["**/charge/demo_2.cpp", "**/supercharge.cpp"],
30 "gitignore": True,
31 "comment_type": CommentType.cpp.value,
32}
33ANALYSE_CONFIG_TEMPLATE = {
34 "get_oneline_needs": True,
35 "oneline_comment_style": ONELINE_COMMENT_TEMPLATE,
36}
37CODELINKS_CONFIG_TEMPLATE = {
38 "outdir": "set/it/to/somewhere",
39 "projects": {
40 "project_1": {
41 "source_discover": SRC_DISCOVER_TEMPLATE,
42 "analyse": ANALYSE_CONFIG_TEMPLATE,
43 }
44 },
45}
46
47
48runner = CliRunner()
49
50
51def _normalize_output(text: str) -> str:
52 """Normalize rich panel output: strip ANSI escapes, then collapse box-drawing
53 chars and whitespace.
54
55 Typer wraps error messages in rich panels whose line breaks depend on terminal
56 width, which can cause substring assertions to fail. And typer forces a colour
57 terminal when ``GITHUB_ACTIONS`` is set, so on CI the panel also carries ANSI
58 escape codes -- including between the halves of a wrapped line -- which have to go
59 first, before the whitespace between the halves can collapse.
60 """
61 text = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
62 # Remove box-drawing characters (─│╭╮╯╰) and collapse resulting whitespace
63 text = re.sub(r"[─│╭╮╯╰]", " ", text)
64 return re.sub(r"\s+", " ", text).strip()
65
66
67@pytest.mark.parametrize(
68 ("config_path"),
69 [
70 (DATA_DIR / "configs" / "minimum_config.toml"),
71 (DATA_DIR / "configs" / "full_config.toml"),
72 ],
73)
74def test_analyse(config_path: Path, tmp_path: Path) -> None:
75 options: list[str] = ["analyse", str(config_path), "--outdir", str(tmp_path)]
76 result = runner.invoke(app, options)
77
78 output_path = tmp_path / "marked_content.json"
79
80 assert output_path.exists()
81 assert result.exit_code == 0
82
83 with output_path.open("r") as f:
84 marked_content = json.load(f)
85 assert marked_content
86
87
88def test_analyse_outputs_warnings(tmp_path: Path) -> None:
89 """Test that the analyse CLI command outputs warnings to console."""
90 # Create a config file that will produce warnings
91 src_dir = TEST_DIR / "data" / "oneline_comment_default"
92 config_dict = {
93 "codelinks": {
94 "outdir": str(tmp_path),
95 "projects": {
96 "test_project": {
97 "source_discover": {
98 "src_dir": str(src_dir),
99 "include": ["*.c"],
100 "comment_type": "cpp",
101 },
102 "analyse": {
103 "get_oneline_needs": True,
104 # Use default oneline_comment_style which will cause warnings
105 # for the test file with too many fields
106 },
107 }
108 },
109 }
110 }
111
112 config_file = tmp_path / "test_config.toml"
113 with config_file.open("w", encoding="utf-8") as f:
114 toml.dump(config_dict, f)
115
116 options: list[str] = ["analyse", str(config_file)]
117 result = runner.invoke(app, options)
118
119 assert result.exit_code == 0
120 # Verify that warnings are output to console, one shape for every kind
121 assert "Analyse warning in" in result.output
122 assert "too_many_fields" in result.output
123
124
125def test_analyse_logs_per_project_summary_and_gates_detail(tmp_path: Path) -> None:
126 """Each project gets a default-visible ``codelinks [<project>]`` summary with
127 counts; the per-type breakdown is gated behind --verbose; --quiet silences it."""
128 config_path = DATA_DIR / "configs" / "minimum_config.toml"
129 base = ["analyse", str(config_path), "--outdir", str(tmp_path)]
130
131 # default: per-project summary shown, breakdown hidden
132 result = runner.invoke(app, base)
133 assert result.exit_code == 0
134 assert "codelinks [" in result.output
135 assert "markers" in result.output
136 assert "oneline need" not in result.output
137
138 # --verbose: per-type breakdown also shown
139 verbose_result = runner.invoke(app, [*base, "--verbose"])
140 assert verbose_result.exit_code == 0
141 assert "codelinks [" in verbose_result.output
142 assert "oneline need" in verbose_result.output
143
144 # --quiet: summary silenced
145 quiet_result = runner.invoke(app, [*base, "--quiet"])
146 assert quiet_result.exit_code == 0
147 assert "codelinks [" not in quiet_result.output
148
149
150@pytest.mark.parametrize(
151 ("gitignore", "stdout"),
152 [
153 (False, "4 files discovered"),
154 (True, "3 files discovered"),
155 ],
156)
157def test_discover(gitignore: bool, stdout: str, source_directory: Path) -> None:
158 options = ["discover", str(source_directory)]
159 options.append("--gitignore" if gitignore else "--no-gitignore")
160 result = runner.invoke(app, options)
161 assert result.exit_code == 0
162 assert stdout in result.stdout
163
164
165@pytest.mark.parametrize(
166 ("src_discover_dict", "analyse_dict", "output_lines"),
167 [
168 (
169 {
170 src_key: (123 if src_key == "exclude" else src_value)
171 for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
172 },
173 ANALYSE_CONFIG_TEMPLATE,
174 [
175 "Invalid value: Invalid source discovery configuration:",
176 "Schema validation error in field 'exclude': 123 is not of type 'array'",
177 ],
178 ),
179 (
180 {
181 src_key: (123 if src_key == "include" else src_value)
182 for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
183 },
184 ANALYSE_CONFIG_TEMPLATE,
185 [
186 "Invalid value: Invalid source discovery configuration:",
187 "Schema validation error in field 'include': 123 is not of type 'array'",
188 ],
189 ),
190 (
191 {
192 src_key: (
193 123 if src_key in ("exclude", "include", "src_dir") else src_value
194 )
195 for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
196 },
197 ANALYSE_CONFIG_TEMPLATE,
198 [
199 "Invalid value: Invalid source discovery configuration:",
200 "Schema validation error in field 'src_dir': 123 is not of type 'string'",
201 "Schema validation error in field 'exclude': 123 is not of type 'array'",
202 "Schema validation error in field 'include': 123 is not of type 'array'",
203 ],
204 ),
205 (
206 SRC_DISCOVER_TEMPLATE,
207 {
208 analyse_key: (
209 {"not_expected": 123}
210 if analyse_key == "oneline_comment_style"
211 else analyse_value
212 )
213 for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
214 },
215 [
216 "Invalid value: Invalid oneline comment style configuration:",
217 "OneLineCommentStyle.__init__() got an unexpected keyword argument",
218 "'not_expected'",
219 ],
220 ),
221 (
222 SRC_DISCOVER_TEMPLATE,
223 {
224 analyse_key: (
225 {"needs_fields": [{"name": "id"}, {"name": "id"}]}
226 if analyse_key == "oneline_comment_style"
227 else analyse_value
228 )
229 for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
230 },
231 [
232 "Invalid value: OneLineCommentStyle configuration errors:",
233 "Missing required fields: ['title', 'type']",
234 "Field 'id' is defined multiple times.",
235 ],
236 ),
237 ],
238)
239def test_analyse_config_negative(
240 src_discover_dict, analyse_dict, output_lines, tmp_path: Path
241) -> None:
242 config_file = tmp_path / "codelinks_config.toml"
243 codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
244 codelink_dict["codelinks"]["projects"]["project_1"]["source_discover"] = (
245 src_discover_dict
246 )
247 codelink_dict["codelinks"]["projects"]["project_1"]["analyse"] = analyse_dict
248 with config_file.open("w", encoding="utf-8") as f:
249 toml.dump(codelink_dict, f)
250
251 options = [
252 "analyse",
253 str(config_file),
254 ]
255 result = runner.invoke(app, options)
256 assert result.exit_code != 0
257 normalized = _normalize_output(result.output)
258 for line in output_lines:
259 assert line in normalized
260
261
262@pytest.mark.parametrize(
263 ("projects", "output_lines"),
264 [
265 (
266 ["project_1", "project_2"],
267 [
268 "The following projects are not found:",
269 "project_2",
270 ],
271 ),
272 ],
273)
274def test_analyse_project_negative(projects, output_lines, tmp_path: Path) -> None:
275 config_file = tmp_path / "codelinks_config.toml"
276 codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
277 with config_file.open("w", encoding="utf-8") as f:
278 toml.dump(codelink_dict, f)
279 projects_config = []
280 for project in projects:
281 projects_config.append("--project")
282 projects_config.append(project)
283
284 options = [
285 "analyse",
286 str(config_file),
287 ]
288 options.extend(projects_config)
289 result = runner.invoke(app, options)
290 assert result.exit_code != 0
291 normalized = _normalize_output(result.output)
292 for line in output_lines:
293 assert line in normalized
294
295
296def test_write_rst_invalid_json(tmp_path: Path) -> None:
297 json_obj = {
298 "whatever": "json",
299 "not": "expected",
300 }
301 json_text = json.dumps(json_obj)
302 json_text = json_text.replace(",", "")
303 jsonpath = tmp_path / "invalid_json.json"
304 with jsonpath.open("w") as f:
305 f.write(json_text)
306
307 options = [
308 "write",
309 "rst",
310 str(jsonpath),
311 "--outpath",
312 str(tmp_path / "out.rst"),
313 ]
314 result = runner.invoke(app, options)
315
316 assert result.exit_code != 0
317 assert "Expecting" in result.output
318
319
320def test_analyse_never_reads_a_need_ids_comment_as_a_need(tmp_path: Path) -> None:
321 """``codelinks analyse`` on the default one-line style: the ``@need-ids:`` comment
322 is one reference record, and ``marked_content.json`` gains no need from it."""
323 (tmp_path / "refs.cpp").write_text(
324 "// @need-ids: REQ_001, IMPL_X\nvoid a() {}\n", encoding="utf-8"
325 )
326 config = tmp_path / "ubproject.toml"
327 config.write_text(
328 '[codelinks.projects.p.source_discover]\nsrc_dir = "./"\ncomment_type = "cpp"\n',
329 encoding="utf-8",
330 )
331 outdir = tmp_path / "out"
332 outdir.mkdir()
333
334 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
335
336 assert result.exit_code == 0, result.output
337 marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
338 types = [obj["type"] for objs in marked.values() for obj in objs]
339 assert types == ["need-id-refs"]
340
341
342def test_write_rst_still_works_and_says_it_is_deprecated(tmp_path: Path) -> None:
343 """``write rst`` is deprecated, not removed: it writes the file as before, and
344 prints the notice on stderr (stdout carries only what it always did)."""
345 marked = {
346 "project_1": [
347 {
348 "filepath": "src/dummy_1.cpp",
349 "remote_url": "https://example.com/src/dummy_1.cpp#L3",
350 "source_map": {
351 "start": {"row": 2, "column": 13},
352 "end": {"row": 2, "column": 21},
353 },
354 "tagged_scope": None,
355 "need_ids": ["NEED_001"],
356 "marker": "@need-ids:",
357 "type": "need-id-refs",
358 }
359 ]
360 }
361 jsonpath = tmp_path / "marked_content.json"
362 jsonpath.write_text(json.dumps(marked), encoding="utf-8")
363 outpath = tmp_path / "needextend.rst"
364
365 result = runner.invoke(
366 app, ["write", "rst", str(jsonpath), "--outpath", str(outpath)]
367 )
368
369 assert result.exit_code == 0, result.output
370 assert WRITE_RST_DEPRECATED in _normalize_output(result.stderr)
371 assert "deprecated" not in result.stdout
372 assert outpath.read_text(encoding="utf-8") == (
373 ".. needextend:: NEED_001\n"
374 " :remote_url: https://example.com/src/dummy_1.cpp#L3\n\n"
375 )
376
377
378@pytest.mark.parametrize(
379 ("json_objs", "output_lines"),
380 [
381 (
382 [
383 {
384 "filepath": 123,
385 "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/multiline_needs/dummy_1.cpp#L4",
386 "source_map": {
387 "start": {"row": 3, "column": 8},
388 "end": {"row": 3, "column": 61},
389 },
390 "tagged_scope": "void dummy_func1(){\n //...\n }",
391 "type": "need-id-refs",
392 }
393 ],
394 [
395 "Invalid value: Errors occurred",
396 "Schema validation error in field 'filepath': 123 is not of type 'string'",
397 "Marker is required for marked content of type 'need_id_refs'",
398 "Need id refs are required for marked content of type 'need_id_refs'",
399 ],
400 ),
401 ],
402)
403def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None:
404 to_dump = {"project_1": json_objs}
405 jsonpath = Path("invalid_objs.json")
406 with jsonpath.open("w") as f:
407 json.dump(to_dump, f)
408 outpath = tmp_path / "needextend.rst"
409 options = [
410 "write",
411 "rst",
412 str(jsonpath),
413 "--outpath",
414 str(outpath),
415 ]
416 result = runner.invoke(app, options)
417
418 assert result.exit_code != 0
419 normalized = _normalize_output(result.output)
420 for line in output_lines:
421 assert line in normalized
422
423
424def test_analyse_with_relative_git_root(tmp_path: Path) -> None:
425 """Test that relative git_root is resolved relative to the config file location."""
426 # Create a fake git repo structure
427 fake_git_root = tmp_path / "fake_repo"
428 fake_git_root.mkdir()
429 (fake_git_root / ".git").mkdir()
430 git_config = fake_git_root / ".git" / "config"
431 git_config.write_text(
432 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
433 )
434 git_head = fake_git_root / ".git" / "HEAD"
435 git_head.write_text("ref: refs/heads/main\n")
436 refs_dir = fake_git_root / ".git" / "refs" / "heads"
437 refs_dir.mkdir(parents=True)
438 (refs_dir / "main").write_text("abc123def456\n")
439
440 # Create source file
441 src_dir = fake_git_root / "src"
442 src_dir.mkdir()
443 src_file = src_dir / "test.c"
444 src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n")
445
446 # Create config in a subdirectory using a RELATIVE git_root path
447 config_dir = tmp_path / "config"
448 config_dir.mkdir()
449 config_file = config_dir / "codelinks.toml"
450 config_content = """
451[codelinks.projects.test_project.source_discover]
452src_dir = "../fake_repo/src"
453gitignore = false
454
455[codelinks.projects.test_project.analyse]
456get_oneline_needs = true
457git_root = "../fake_repo"
458"""
459 config_file.write_text(config_content)
460
461 outdir = tmp_path / "output"
462 outdir.mkdir()
463
464 options = ["analyse", str(config_file), "--outdir", str(outdir)]
465 result = runner.invoke(app, options)
466
467 assert result.exit_code == 0, f"CLI failed: {result.stdout}"
468 output_path = outdir / "marked_content.json"
469 assert output_path.exists()
470
471 with output_path.open("r") as f:
472 marked_content = json.load(f)
473 # Verify the content was analysed using the correct git_root
474 assert len(marked_content["test_project"]) > 0
475
476
477def test_analyse_with_absolute_git_root(tmp_path: Path) -> None:
478 """Test that absolute git_root is used as-is."""
479 # Create a fake git repo structure
480 fake_git_root = tmp_path / "fake_repo"
481 fake_git_root.mkdir()
482 (fake_git_root / ".git").mkdir()
483 git_config = fake_git_root / ".git" / "config"
484 git_config.write_text(
485 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
486 )
487 git_head = fake_git_root / ".git" / "HEAD"
488 git_head.write_text("ref: refs/heads/main\n")
489 refs_dir = fake_git_root / ".git" / "refs" / "heads"
490 refs_dir.mkdir(parents=True)
491 (refs_dir / "main").write_text("abc123def456\n")
492
493 # Create source file
494 src_dir = fake_git_root / "src"
495 src_dir.mkdir()
496 src_file = src_dir / "test.c"
497 src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n")
498
499 # Create config in a different location using an ABSOLUTE git_root path
500 config_dir = tmp_path / "config"
501 config_dir.mkdir()
502 config_file = config_dir / "codelinks.toml"
503 # Use absolute path for both src_dir and git_root
504 config_content = f"""
505[codelinks.projects.test_project.source_discover]
506src_dir = "{src_dir.as_posix()}"
507gitignore = false
508
509[codelinks.projects.test_project.analyse]
510get_oneline_needs = true
511git_root = "{fake_git_root.as_posix()}"
512"""
513 config_file.write_text(config_content)
514
515 outdir = tmp_path / "output"
516 outdir.mkdir()
517
518 options = ["analyse", str(config_file), "--outdir", str(outdir)]
519 result = runner.invoke(app, options)
520
521 assert result.exit_code == 0, f"CLI failed: {result.stdout}"
522 output_path = outdir / "marked_content.json"
523 assert output_path.exists()
524
525 with output_path.open("r") as f:
526 marked_content = json.load(f)
527 # Verify the content was analysed using the correct git_root
528 assert len(marked_content["test_project"]) > 0
529
530
531# -- the TOML reader of `codelinks analyse` ----------------------------------------------
532
533
534def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]:
535 config = tmp_path / "cl.toml"
536 config.write_text(toml_text, encoding="utf-8")
537 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)])
538 return result.exit_code, _normalize_output(result.output)
539
540
541def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None:
542 """The parser's own message reaches the user, not only "Failed to load"."""
543 broken = "[codelinks\n"
544 with pytest.raises(tomllib.TOMLDecodeError) as parse_error:
545 tomllib.loads(broken)
546
547 exit_code, output = _analyse(tmp_path, broken)
548
549 assert exit_code == 2
550 assert _normalize_output(str(parse_error.value)) in output
551 # ub-project's own words, not the catch-all's
552 assert "Failed to load" not in output
553
554
555@pytest.mark.parametrize(
556 ("value", "type_name"),
557 [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")],
558)
559def test_analyse_codelinks_not_a_table(
560 tmp_path: Path, value: str, type_name: str
561) -> None:
562 """``codelinks`` that is not a table is named as such, for a falsy value too."""
563 exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n")
564
565 assert exit_code == 2
566 assert f"[codelinks] must be a table, got {type_name}" in output
567 assert "No 'codelinks' section" not in output
568 assert "Failed to load" not in output
569
570
571@pytest.mark.parametrize(
572 "toml_text",
573 [
574 pytest.param("[codelinks]\n", id="empty-table"),
575 pytest.param("[needs]\nid_required = true\n", id="no-table"),
576 ],
577)
578def test_analyse_without_codelinks_configuration(
579 tmp_path: Path, toml_text: str
580) -> None:
581 """A missing or empty ``[codelinks]`` keeps today's words."""
582 exit_code, output = _analyse(tmp_path, toml_text)
583
584 assert exit_code == 2
585 assert "No 'codelinks' section found" in output
586
587
588def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None:
589 """``tomllib`` can fail with an exception ub-project does not wrap (a
590 ``RecursionError`` on a pathologically nested file): still a usage error, rc 2,
591 never a traceback."""
592 exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n")
593
594 # 1 would be the uncaught exception; typer reports a BadParameter as 2
595 assert exit_code == 2
596
597
598def _fake_git_repo(root: Path) -> None:
599 """A repository on GitHub with one commit: ``config``, ``HEAD`` and one ref."""
600 (root / ".git" / "refs" / "heads").mkdir(parents=True)
601 (root / ".git" / "config").write_text(
602 '[remote "origin"]\n url = https://github.com/test/repo.git\n',
603 encoding="utf-8",
604 )
605 (root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
606 (root / ".git" / "refs" / "heads" / "main").write_text(
607 "abc123def456\n", encoding="utf-8"
608 )
609
610
611def test_analyse_writes_a_multiline_need_record(tmp_path: Path) -> None:
612 """``codelinks analyse`` writes a multi-line need as one ``multiline-need`` record:
613 the need with its option values as written, the resolved markup, and a source whose
614 path is relative to the repository root."""
615 repo = tmp_path / "repo"
616 _fake_git_repo(repo)
617 (repo / "src").mkdir()
618 src_file = repo / "src" / "demo.cpp"
619 src_file.write_text(
620 "// @need[md] impl: Demo need\n"
621 "// :id: IMPL_DEMO\n"
622 "// :links: REQ_1, REQ_2\n"
623 "//\n"
624 "// Calls *nothing*.\n"
625 "// @endneed\n"
626 "void demo() {}\n",
627 encoding="utf-8",
628 )
629 config = repo / "ubproject.toml"
630 config.write_text(
631 "[codelinks.projects.demo.source_discover]\n"
632 'src_dir = "src"\n'
633 'comment_type = "cpp"\n'
634 "gitignore = false\n"
635 "[codelinks.projects.demo.analyse]\n"
636 "get_multiline_needs = true\n",
637 encoding="utf-8",
638 )
639 outdir = tmp_path / "out"
640 outdir.mkdir()
641
642 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
643
644 assert result.exit_code == 0, result.output
645 marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
646 (record,) = marked["demo"]
647 assert Path(record.pop("filepath")) == src_file.resolve()
648 assert record == {
649 "remote_url": "https://github.com/test/repo/blob/abc123def456/src/demo.cpp#L1",
650 "source_map": {
651 "start": {"row": 0, "column": 3},
652 "end": {"row": 5, "column": 11},
653 },
654 "tagged_scope": "void demo() {}",
655 "type": "multiline-need",
656 "need": {
657 "type": "impl",
658 "title": "Demo need",
659 "id": "IMPL_DEMO",
660 "links": "REQ_1, REQ_2",
661 "content": "Calls *nothing*.",
662 "doctype": ".md",
663 },
664 "markup": "md",
665 "source": {
666 "project": "demo",
667 "path": "src/demo.cpp",
668 "root": "git",
669 "commit": "abc123def456",
670 "start": {"line": 1, "col": 3},
671 "end": {"line": 6, "col": 11},
672 "content_start": {"line": 5, "col": 3},
673 "option_lines": {"id": 2, "links": 3},
674 "scope": {"kind": "function_definition", "start": 7, "end": 7},
675 },
676 }
677
678
679def test_analyse_prints_a_multiline_need_warning_like_a_oneline_one(
680 tmp_path: Path,
681) -> None:
682 """A refused block is printed in the one shape every analyse warning takes."""
683 (tmp_path / "open.cpp").write_text(
684 "// @need impl: Never closed\nvoid f() {}\n", encoding="utf-8"
685 )
686 config = tmp_path / "ubproject.toml"
687 config.write_text(
688 "[codelinks.projects.p.source_discover]\n"
689 'src_dir = "./"\n'
690 'comment_type = "cpp"\n'
691 "[codelinks.projects.p.analyse]\n"
692 "get_multiline_needs = true\n",
693 encoding="utf-8",
694 )
695 outdir = tmp_path / "out"
696 outdir.mkdir()
697
698 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
699
700 assert result.exit_code == 0, result.output
701 # rich wraps the console at its width, anywhere in a long path: compare without
702 # whitespace
703 expected = (
704 f"Analyse warning in {tmp_path.resolve() / 'open.cpp'}:1 - "
705 "multiline_need_unterminated: no '@endneed' line before the comment ends"
706 )
707 assert re.sub(r"\s+", "", expected) in re.sub(r"\s+", "", result.output)
708
709
710@pytest.mark.parametrize(
711 ("analyse", "message"),
712 [
713 pytest.param(
714 {"get_rst": True},
715 "analyse: 'get_rst' is no longer supported: the marked-rst blocks were "
716 "replaced by multi-line needs (the @need and @endneed markers); use "
717 "get_multiline_needs instead",
718 id="get_rst",
719 ),
720 pytest.param(
721 {"marked_rst": {"start_sequence": "@rst"}},
722 "analyse: '[analyse.marked_rst]' is no longer supported",
723 id="marked_rst",
724 ),
725 pytest.param(
726 {"get_rst": False, "marked_rst": {"end_sequence": "@endrst"}},
727 "analyse: 'get_rst' and '[analyse.marked_rst]' are no longer supported: "
728 "the marked-rst blocks were replaced by multi-line needs (the @need and "
729 "@endneed markers); use get_multiline_needs and [analyse.multiline_needs] "
730 "instead",
731 id="both",
732 ),
733 ],
734)
735def test_analyse_names_the_replacement_of_a_removed_key(
736 analyse: dict, message: str, tmp_path: Path
737) -> None:
738 """The keys of the removed marked-rst blocks stop the CLI with one message naming
739 what replaces them, through the channel of every other configuration error."""
740 config_file = tmp_path / "codelinks_config.toml"
741 with config_file.open("w", encoding="utf-8") as f:
742 toml.dump(
743 {
744 "codelinks": {
745 "projects": {
746 "p": {
747 "source_discover": {"src_dir": str(tmp_path)},
748 "analyse": analyse,
749 }
750 }
751 }
752 },
753 f,
754 )
755
756 result = runner.invoke(app, ["analyse", str(config_file)])
757
758 assert result.exit_code != 0
759 assert f"Invalid value: {message}" in _normalize_output(result.output)