[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
165def test_discover_cli_default_exclude_matches_ts_default(tmp_path: Path) -> None:
166 """Without ``-e``, ``discover --comment-type ts`` excludes what a ``ts`` project's
167 configuration does: ``dist/app.js`` is not listed, ``src/app.ts`` is."""
168 (tmp_path / "src").mkdir()
169 (tmp_path / "src" / "app.ts").write_text(
170 "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
171 )
172 (tmp_path / "dist").mkdir()
173 (tmp_path / "dist" / "app.js").write_text(
174 "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
175 )
176
177 result = runner.invoke(
178 app,
179 ["discover", str(tmp_path), "--comment-type", "ts", "--no-gitignore"],
180 )
181 assert result.exit_code == 0
182 assert "1 files discovered" in result.stdout
183 assert str(tmp_path / "src" / "app.ts") in result.stdout
184 assert str(tmp_path / "dist" / "app.js") not in result.stdout
185
186
187def test_discover_cli_excludes_replace_the_ts_default(tmp_path: Path) -> None:
188 """``-e`` replaces the ``ts`` default rather than adding to it: the test file
189 it names is dropped, and ``dist/app.js``, which the default would drop, is
190 listed."""
191 (tmp_path / "src").mkdir()
192 for name in ("app.ts", "app.test.ts"):
193 (tmp_path / "src" / name).write_text(
194 "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
195 )
196 (tmp_path / "dist").mkdir()
197 (tmp_path / "dist" / "app.js").write_text(
198 "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
199 )
200
201 result = runner.invoke(
202 app,
203 [
204 "discover",
205 str(tmp_path),
206 "--comment-type",
207 "ts",
208 "--no-gitignore",
209 "-e",
210 "**/*.test.ts",
211 ],
212 )
213 assert result.exit_code == 0
214 assert "2 files discovered" in result.stdout
215 assert str(tmp_path / "src" / "app.ts") in result.stdout
216 assert str(tmp_path / "dist" / "app.js") in result.stdout
217 assert str(tmp_path / "src" / "app.test.ts") not in result.stdout
218
219
220def test_discover_cli_cpp_default_exclude_is_empty(tmp_path: Path) -> None:
221 """Without ``-e``, ``discover`` for ``cpp`` (the CLI's default) excludes nothing:
222 ``lib/widget.cpp`` is listed."""
223 lib_dir = tmp_path / "lib"
224 lib_dir.mkdir()
225 (lib_dir / "widget.cpp").write_text(
226 "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
227 )
228
229 result = runner.invoke(app, ["discover", str(tmp_path), "--no-gitignore"])
230 assert result.exit_code == 0
231 assert "1 files discovered" in result.stdout
232 assert str(lib_dir / "widget.cpp") in result.stdout
233
234
235@pytest.mark.parametrize(
236 ("src_discover_dict", "analyse_dict", "output_lines"),
237 [
238 (
239 {
240 src_key: (123 if src_key == "exclude" else src_value)
241 for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
242 },
243 ANALYSE_CONFIG_TEMPLATE,
244 [
245 "Invalid value: Invalid source discovery configuration:",
246 "Schema validation error in field 'exclude': 123 is not of type 'array'",
247 ],
248 ),
249 (
250 {
251 src_key: (123 if src_key == "include" else src_value)
252 for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
253 },
254 ANALYSE_CONFIG_TEMPLATE,
255 [
256 "Invalid value: Invalid source discovery configuration:",
257 "Schema validation error in field 'include': 123 is not of type 'array'",
258 ],
259 ),
260 (
261 {
262 src_key: (
263 123 if src_key in ("exclude", "include", "src_dir") else src_value
264 )
265 for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
266 },
267 ANALYSE_CONFIG_TEMPLATE,
268 [
269 "Invalid value: Invalid source discovery configuration:",
270 "Schema validation error in field 'src_dir': 123 is not of type 'string'",
271 "Schema validation error in field 'exclude': 123 is not of type 'array'",
272 "Schema validation error in field 'include': 123 is not of type 'array'",
273 ],
274 ),
275 (
276 SRC_DISCOVER_TEMPLATE,
277 {
278 analyse_key: (
279 {"not_expected": 123}
280 if analyse_key == "oneline_comment_style"
281 else analyse_value
282 )
283 for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
284 },
285 [
286 "Invalid value: Invalid oneline comment style configuration:",
287 "OneLineCommentStyle.__init__() got an unexpected keyword argument",
288 "'not_expected'",
289 ],
290 ),
291 (
292 SRC_DISCOVER_TEMPLATE,
293 {
294 analyse_key: (
295 {"needs_fields": [{"name": "id"}, {"name": "id"}]}
296 if analyse_key == "oneline_comment_style"
297 else analyse_value
298 )
299 for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
300 },
301 [
302 "Invalid value: OneLineCommentStyle configuration errors:",
303 "Missing required fields: ['title', 'type']",
304 "Field 'id' is defined multiple times.",
305 ],
306 ),
307 ],
308)
309def test_analyse_config_negative(
310 src_discover_dict, analyse_dict, output_lines, tmp_path: Path
311) -> None:
312 config_file = tmp_path / "codelinks_config.toml"
313 codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
314 codelink_dict["codelinks"]["projects"]["project_1"]["source_discover"] = (
315 src_discover_dict
316 )
317 codelink_dict["codelinks"]["projects"]["project_1"]["analyse"] = analyse_dict
318 with config_file.open("w", encoding="utf-8") as f:
319 toml.dump(codelink_dict, f)
320
321 options = [
322 "analyse",
323 str(config_file),
324 ]
325 result = runner.invoke(app, options)
326 assert result.exit_code != 0
327 normalized = _normalize_output(result.output)
328 for line in output_lines:
329 assert line in normalized
330
331
332@pytest.mark.parametrize(
333 ("projects", "output_lines"),
334 [
335 (
336 ["project_1", "project_2"],
337 [
338 "The following projects are not found:",
339 "project_2",
340 ],
341 ),
342 ],
343)
344def test_analyse_project_negative(projects, output_lines, tmp_path: Path) -> None:
345 config_file = tmp_path / "codelinks_config.toml"
346 codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
347 with config_file.open("w", encoding="utf-8") as f:
348 toml.dump(codelink_dict, f)
349 projects_config = []
350 for project in projects:
351 projects_config.append("--project")
352 projects_config.append(project)
353
354 options = [
355 "analyse",
356 str(config_file),
357 ]
358 options.extend(projects_config)
359 result = runner.invoke(app, options)
360 assert result.exit_code != 0
361 normalized = _normalize_output(result.output)
362 for line in output_lines:
363 assert line in normalized
364
365
366def test_write_rst_invalid_json(tmp_path: Path) -> None:
367 json_obj = {
368 "whatever": "json",
369 "not": "expected",
370 }
371 json_text = json.dumps(json_obj)
372 json_text = json_text.replace(",", "")
373 jsonpath = tmp_path / "invalid_json.json"
374 with jsonpath.open("w") as f:
375 f.write(json_text)
376
377 options = [
378 "write",
379 "rst",
380 str(jsonpath),
381 "--outpath",
382 str(tmp_path / "out.rst"),
383 ]
384 result = runner.invoke(app, options)
385
386 assert result.exit_code != 0
387 assert "Expecting" in result.output
388
389
390def test_analyse_never_reads_a_need_ids_comment_as_a_need(tmp_path: Path) -> None:
391 """``codelinks analyse`` on the default one-line style: the ``@need-ids:`` comment
392 is one reference record, and ``marked_content.json`` gains no need from it."""
393 (tmp_path / "refs.cpp").write_text(
394 "// @need-ids: REQ_001, IMPL_X\nvoid a() {}\n", encoding="utf-8"
395 )
396 config = tmp_path / "ubproject.toml"
397 config.write_text(
398 '[codelinks.projects.p.source_discover]\nsrc_dir = "./"\ncomment_type = "cpp"\n',
399 encoding="utf-8",
400 )
401 outdir = tmp_path / "out"
402 outdir.mkdir()
403
404 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
405
406 assert result.exit_code == 0, result.output
407 marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
408 types = [obj["type"] for objs in marked.values() for obj in objs]
409 assert types == ["need-id-refs"]
410
411
412def test_write_rst_still_works_and_says_it_is_deprecated(tmp_path: Path) -> None:
413 """``write rst`` is deprecated, not removed: it writes the file as before, and
414 prints the notice on stderr (stdout carries only what it always did)."""
415 marked = {
416 "project_1": [
417 {
418 "filepath": "src/dummy_1.cpp",
419 "remote_url": "https://example.com/src/dummy_1.cpp#L3",
420 "source_map": {
421 "start": {"row": 2, "column": 13},
422 "end": {"row": 2, "column": 21},
423 },
424 "tagged_scope": None,
425 "need_ids": ["NEED_001"],
426 "marker": "@need-ids:",
427 "type": "need-id-refs",
428 }
429 ]
430 }
431 jsonpath = tmp_path / "marked_content.json"
432 jsonpath.write_text(json.dumps(marked), encoding="utf-8")
433 outpath = tmp_path / "needextend.rst"
434
435 result = runner.invoke(
436 app, ["write", "rst", str(jsonpath), "--outpath", str(outpath)]
437 )
438
439 assert result.exit_code == 0, result.output
440 assert WRITE_RST_DEPRECATED in _normalize_output(result.stderr)
441 assert "deprecated" not in result.stdout
442 assert outpath.read_text(encoding="utf-8") == (
443 ".. needextend:: NEED_001\n"
444 " :remote_url: https://example.com/src/dummy_1.cpp#L3\n\n"
445 )
446
447
448@pytest.mark.parametrize(
449 ("json_objs", "output_lines"),
450 [
451 (
452 [
453 {
454 "filepath": 123,
455 "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/multiline_needs/dummy_1.cpp#L4",
456 "source_map": {
457 "start": {"row": 3, "column": 8},
458 "end": {"row": 3, "column": 61},
459 },
460 "tagged_scope": "void dummy_func1(){\n //...\n }",
461 "type": "need-id-refs",
462 }
463 ],
464 [
465 "Invalid value: Errors occurred",
466 "Schema validation error in field 'filepath': 123 is not of type 'string'",
467 "Marker is required for marked content of type 'need_id_refs'",
468 "Need id refs are required for marked content of type 'need_id_refs'",
469 ],
470 ),
471 ],
472)
473def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None:
474 to_dump = {"project_1": json_objs}
475 jsonpath = Path("invalid_objs.json")
476 with jsonpath.open("w") as f:
477 json.dump(to_dump, f)
478 outpath = tmp_path / "needextend.rst"
479 options = [
480 "write",
481 "rst",
482 str(jsonpath),
483 "--outpath",
484 str(outpath),
485 ]
486 result = runner.invoke(app, options)
487
488 assert result.exit_code != 0
489 normalized = _normalize_output(result.output)
490 for line in output_lines:
491 assert line in normalized
492
493
494def test_analyse_with_relative_git_root(tmp_path: Path) -> None:
495 """Test that relative git_root is resolved relative to the config file location."""
496 # Create a fake git repo structure
497 fake_git_root = tmp_path / "fake_repo"
498 fake_git_root.mkdir()
499 (fake_git_root / ".git").mkdir()
500 git_config = fake_git_root / ".git" / "config"
501 git_config.write_text(
502 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
503 )
504 git_head = fake_git_root / ".git" / "HEAD"
505 git_head.write_text("ref: refs/heads/main\n")
506 refs_dir = fake_git_root / ".git" / "refs" / "heads"
507 refs_dir.mkdir(parents=True)
508 (refs_dir / "main").write_text("abc123def456\n")
509
510 # Create source file
511 src_dir = fake_git_root / "src"
512 src_dir.mkdir()
513 src_file = src_dir / "test.c"
514 src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n")
515
516 # Create config in a subdirectory using a RELATIVE git_root path
517 config_dir = tmp_path / "config"
518 config_dir.mkdir()
519 config_file = config_dir / "codelinks.toml"
520 config_content = """
521[codelinks.projects.test_project.source_discover]
522src_dir = "../fake_repo/src"
523gitignore = false
524
525[codelinks.projects.test_project.analyse]
526get_oneline_needs = true
527git_root = "../fake_repo"
528"""
529 config_file.write_text(config_content)
530
531 outdir = tmp_path / "output"
532 outdir.mkdir()
533
534 options = ["analyse", str(config_file), "--outdir", str(outdir)]
535 result = runner.invoke(app, options)
536
537 assert result.exit_code == 0, f"CLI failed: {result.stdout}"
538 output_path = outdir / "marked_content.json"
539 assert output_path.exists()
540
541 with output_path.open("r") as f:
542 marked_content = json.load(f)
543 # Verify the content was analysed using the correct git_root
544 assert len(marked_content["test_project"]) > 0
545
546
547def test_analyse_with_absolute_git_root(tmp_path: Path) -> None:
548 """Test that absolute git_root is used as-is."""
549 # Create a fake git repo structure
550 fake_git_root = tmp_path / "fake_repo"
551 fake_git_root.mkdir()
552 (fake_git_root / ".git").mkdir()
553 git_config = fake_git_root / ".git" / "config"
554 git_config.write_text(
555 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
556 )
557 git_head = fake_git_root / ".git" / "HEAD"
558 git_head.write_text("ref: refs/heads/main\n")
559 refs_dir = fake_git_root / ".git" / "refs" / "heads"
560 refs_dir.mkdir(parents=True)
561 (refs_dir / "main").write_text("abc123def456\n")
562
563 # Create source file
564 src_dir = fake_git_root / "src"
565 src_dir.mkdir()
566 src_file = src_dir / "test.c"
567 src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n")
568
569 # Create config in a different location using an ABSOLUTE git_root path
570 config_dir = tmp_path / "config"
571 config_dir.mkdir()
572 config_file = config_dir / "codelinks.toml"
573 # Use absolute path for both src_dir and git_root
574 config_content = f"""
575[codelinks.projects.test_project.source_discover]
576src_dir = "{src_dir.as_posix()}"
577gitignore = false
578
579[codelinks.projects.test_project.analyse]
580get_oneline_needs = true
581git_root = "{fake_git_root.as_posix()}"
582"""
583 config_file.write_text(config_content)
584
585 outdir = tmp_path / "output"
586 outdir.mkdir()
587
588 options = ["analyse", str(config_file), "--outdir", str(outdir)]
589 result = runner.invoke(app, options)
590
591 assert result.exit_code == 0, f"CLI failed: {result.stdout}"
592 output_path = outdir / "marked_content.json"
593 assert output_path.exists()
594
595 with output_path.open("r") as f:
596 marked_content = json.load(f)
597 # Verify the content was analysed using the correct git_root
598 assert len(marked_content["test_project"]) > 0
599
600
601# -- the TOML reader of `codelinks analyse` ----------------------------------------------
602
603
604def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]:
605 config = tmp_path / "cl.toml"
606 config.write_text(toml_text, encoding="utf-8")
607 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)])
608 return result.exit_code, _normalize_output(result.output)
609
610
611def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None:
612 """The parser's own message reaches the user, not only "Failed to load"."""
613 broken = "[codelinks\n"
614 with pytest.raises(tomllib.TOMLDecodeError) as parse_error:
615 tomllib.loads(broken)
616
617 exit_code, output = _analyse(tmp_path, broken)
618
619 assert exit_code == 2
620 assert _normalize_output(str(parse_error.value)) in output
621 # ub-project's own words, not the catch-all's
622 assert "Failed to load" not in output
623
624
625@pytest.mark.parametrize(
626 ("value", "type_name"),
627 [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")],
628)
629def test_analyse_codelinks_not_a_table(
630 tmp_path: Path, value: str, type_name: str
631) -> None:
632 """``codelinks`` that is not a table is named as such, for a falsy value too."""
633 exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n")
634
635 assert exit_code == 2
636 assert f"[codelinks] must be a table, got {type_name}" in output
637 assert "No 'codelinks' section" not in output
638 assert "Failed to load" not in output
639
640
641@pytest.mark.parametrize(
642 "toml_text",
643 [
644 pytest.param("[codelinks]\n", id="empty-table"),
645 pytest.param("[needs]\nid_required = true\n", id="no-table"),
646 ],
647)
648def test_analyse_without_codelinks_configuration(
649 tmp_path: Path, toml_text: str
650) -> None:
651 """A missing or empty ``[codelinks]`` keeps today's words."""
652 exit_code, output = _analyse(tmp_path, toml_text)
653
654 assert exit_code == 2
655 assert "No 'codelinks' section found" in output
656
657
658def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None:
659 """``tomllib`` can fail with an exception ub-project does not wrap (a
660 ``RecursionError`` on a pathologically nested file): still a usage error, rc 2,
661 never a traceback."""
662 exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n")
663
664 # 1 would be the uncaught exception; typer reports a BadParameter as 2
665 assert exit_code == 2
666
667
668def _fake_git_repo(root: Path) -> None:
669 """A repository on GitHub with one commit: ``config``, ``HEAD`` and one ref."""
670 (root / ".git" / "refs" / "heads").mkdir(parents=True)
671 (root / ".git" / "config").write_text(
672 '[remote "origin"]\n url = https://github.com/test/repo.git\n',
673 encoding="utf-8",
674 )
675 (root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
676 (root / ".git" / "refs" / "heads" / "main").write_text(
677 "abc123def456\n", encoding="utf-8"
678 )
679
680
681def test_analyse_writes_a_multiline_need_record(tmp_path: Path) -> None:
682 """``codelinks analyse`` writes a multi-line need as one ``multiline-need`` record:
683 the need with its option values as written, the resolved markup, and a source whose
684 path is relative to the repository root."""
685 repo = tmp_path / "repo"
686 _fake_git_repo(repo)
687 (repo / "src").mkdir()
688 src_file = repo / "src" / "demo.cpp"
689 src_file.write_text(
690 "// @need[md] impl: Demo need\n"
691 "// :id: IMPL_DEMO\n"
692 "// :links: REQ_1, REQ_2\n"
693 "//\n"
694 "// Calls *nothing*.\n"
695 "// @endneed\n"
696 "void demo() {}\n",
697 encoding="utf-8",
698 )
699 config = repo / "ubproject.toml"
700 config.write_text(
701 "[codelinks.projects.demo.source_discover]\n"
702 'src_dir = "src"\n'
703 'comment_type = "cpp"\n'
704 "gitignore = false\n"
705 "[codelinks.projects.demo.analyse]\n"
706 "get_multiline_needs = true\n",
707 encoding="utf-8",
708 )
709 outdir = tmp_path / "out"
710 outdir.mkdir()
711
712 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
713
714 assert result.exit_code == 0, result.output
715 marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
716 (record,) = marked["demo"]
717 assert Path(record.pop("filepath")) == src_file.resolve()
718 assert record == {
719 "remote_url": "https://github.com/test/repo/blob/abc123def456/src/demo.cpp#L1",
720 "source_map": {
721 "start": {"row": 0, "column": 3},
722 "end": {"row": 5, "column": 11},
723 },
724 "tagged_scope": "void demo() {}",
725 "type": "multiline-need",
726 "need": {
727 "type": "impl",
728 "title": "Demo need",
729 "id": "IMPL_DEMO",
730 "links": "REQ_1, REQ_2",
731 "content": "Calls *nothing*.",
732 "doctype": ".md",
733 },
734 "markup": "md",
735 "source": {
736 "project": "demo",
737 "path": "src/demo.cpp",
738 "root": "git",
739 "commit": "abc123def456",
740 "start": {"line": 1, "col": 3},
741 "end": {"line": 6, "col": 11},
742 "content_start": {"line": 5, "col": 3},
743 "option_lines": {"id": 2, "links": 3},
744 "scope": {"kind": "function_definition", "start": 7, "end": 7},
745 },
746 }
747
748
749def test_analyse_prints_a_multiline_need_warning_like_a_oneline_one(
750 tmp_path: Path,
751) -> None:
752 """A refused block is printed in the one shape every analyse warning takes."""
753 (tmp_path / "open.cpp").write_text(
754 "// @need impl: Never closed\nvoid f() {}\n", encoding="utf-8"
755 )
756 config = tmp_path / "ubproject.toml"
757 config.write_text(
758 "[codelinks.projects.p.source_discover]\n"
759 'src_dir = "./"\n'
760 'comment_type = "cpp"\n'
761 "[codelinks.projects.p.analyse]\n"
762 "get_multiline_needs = true\n",
763 encoding="utf-8",
764 )
765 outdir = tmp_path / "out"
766 outdir.mkdir()
767
768 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
769
770 assert result.exit_code == 0, result.output
771 # rich wraps the console at its width, anywhere in a long path: compare without
772 # whitespace
773 expected = (
774 f"Analyse warning in {tmp_path.resolve() / 'open.cpp'}:1 - "
775 "multiline_need_unterminated: no '@endneed' line before the comment ends"
776 )
777 assert re.sub(r"\s+", "", expected) in re.sub(r"\s+", "", result.output)
778
779
780@pytest.mark.parametrize(
781 ("analyse", "message"),
782 [
783 pytest.param(
784 {"get_rst": True},
785 "analyse: 'get_rst' is no longer supported: the marked-rst blocks were "
786 "replaced by multi-line needs (the @need and @endneed markers); use "
787 "get_multiline_needs instead",
788 id="get_rst",
789 ),
790 pytest.param(
791 {"marked_rst": {"start_sequence": "@rst"}},
792 "analyse: '[analyse.marked_rst]' is no longer supported",
793 id="marked_rst",
794 ),
795 pytest.param(
796 {"get_rst": False, "marked_rst": {"end_sequence": "@endrst"}},
797 "analyse: 'get_rst' and '[analyse.marked_rst]' are no longer supported: "
798 "the marked-rst blocks were replaced by multi-line needs (the @need and "
799 "@endneed markers); use get_multiline_needs and [analyse.multiline_needs] "
800 "instead",
801 id="both",
802 ),
803 ],
804)
805def test_analyse_names_the_replacement_of_a_removed_key(
806 analyse: dict, message: str, tmp_path: Path
807) -> None:
808 """The keys of the removed marked-rst blocks stop the CLI with one message naming
809 what replaces them, through the channel of every other configuration error."""
810 config_file = tmp_path / "codelinks_config.toml"
811 with config_file.open("w", encoding="utf-8") as f:
812 toml.dump(
813 {
814 "codelinks": {
815 "projects": {
816 "p": {
817 "source_discover": {"src_dir": str(tmp_path)},
818 "analyse": analyse,
819 }
820 }
821 }
822 },
823 f,
824 )
825
826 result = runner.invoke(app, ["analyse", str(config_file)])
827
828 assert result.exit_code != 0
829 assert f"Invalid value: {message}" in _normalize_output(result.output)
830
831
832@pytest.mark.parametrize(
833 ("selected", "warnings"),
834 [pytest.param([], 2, id="every_project"), pytest.param(["-p", "a"], 1, id="one")],
835)
836def test_analyse_tolerates_get_rst_false(
837 tmp_path: Path, selected: list[str], warnings: int
838) -> None:
839 """``get_rst = false`` is warned about once per project the command analyses, and
840 the analysis runs."""
841 config_file = tmp_path / "codelinks_config.toml"
842 project = {
843 "source_discover": {"src_dir": str(tmp_path)},
844 "analyse": {"get_rst": False},
845 }
846 with config_file.open("w", encoding="utf-8") as f:
847 toml.dump({"codelinks": {"projects": {"a": project, "b": project}}}, f)
848
849 result = runner.invoke(app, ["analyse", str(config_file), *selected])
850
851 assert result.exit_code == 0, result.output
852 output = _normalize_output(result.output)
853 ignored = (
854 "analyse: 'get_rst' is no longer read: the marked-rst blocks were replaced "
855 "by multi-line needs (the @need and @endneed markers); get_rst = false asks "
856 "for nothing and can be removed"
857 )
858 assert output.count(ignored) == warnings
859 # each line names its project
860 named = [name for name in ("a", "b") if f"Project {name!r}: {ignored}" in output]
861 assert named == ["a", "b"][:warnings]