[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
121 assert "Oneline parser warning" 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/marked_rst/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 "rst": ".. impl:: implement dummy function 1\n :id: IMPL_71\n",
392 "type": "need-id-refs",
393 }
394 ],
395 [
396 "Invalid value: Errors occurred",
397 "Schema validation error in field 'filepath': 123 is not of type 'string'",
398 "Marker is required for marked content of type 'need_id_refs'",
399 "Need id refs are required for marked content of type 'need_id_refs'",
400 ],
401 ),
402 ],
403)
404def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None:
405 to_dump = {"project_1": json_objs}
406 jsonpath = Path("invalid_objs.json")
407 with jsonpath.open("w") as f:
408 json.dump(to_dump, f)
409 outpath = tmp_path / "needextend.rst"
410 options = [
411 "write",
412 "rst",
413 str(jsonpath),
414 "--outpath",
415 str(outpath),
416 ]
417 result = runner.invoke(app, options)
418
419 assert result.exit_code != 0
420 normalized = _normalize_output(result.output)
421 for line in output_lines:
422 assert line in normalized
423
424
425def test_analyse_with_relative_git_root(tmp_path: Path) -> None:
426 """Test that relative git_root is resolved relative to the config file location."""
427 # Create a fake git repo structure
428 fake_git_root = tmp_path / "fake_repo"
429 fake_git_root.mkdir()
430 (fake_git_root / ".git").mkdir()
431 git_config = fake_git_root / ".git" / "config"
432 git_config.write_text(
433 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
434 )
435 git_head = fake_git_root / ".git" / "HEAD"
436 git_head.write_text("ref: refs/heads/main\n")
437 refs_dir = fake_git_root / ".git" / "refs" / "heads"
438 refs_dir.mkdir(parents=True)
439 (refs_dir / "main").write_text("abc123def456\n")
440
441 # Create source file
442 src_dir = fake_git_root / "src"
443 src_dir.mkdir()
444 src_file = src_dir / "test.c"
445 src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n")
446
447 # Create config in a subdirectory using a RELATIVE git_root path
448 config_dir = tmp_path / "config"
449 config_dir.mkdir()
450 config_file = config_dir / "codelinks.toml"
451 config_content = """
452[codelinks.projects.test_project.source_discover]
453src_dir = "../fake_repo/src"
454gitignore = false
455
456[codelinks.projects.test_project.analyse]
457get_oneline_needs = true
458git_root = "../fake_repo"
459"""
460 config_file.write_text(config_content)
461
462 outdir = tmp_path / "output"
463 outdir.mkdir()
464
465 options = ["analyse", str(config_file), "--outdir", str(outdir)]
466 result = runner.invoke(app, options)
467
468 assert result.exit_code == 0, f"CLI failed: {result.stdout}"
469 output_path = outdir / "marked_content.json"
470 assert output_path.exists()
471
472 with output_path.open("r") as f:
473 marked_content = json.load(f)
474 # Verify the content was analysed using the correct git_root
475 assert len(marked_content["test_project"]) > 0
476
477
478def test_analyse_with_absolute_git_root(tmp_path: Path) -> None:
479 """Test that absolute git_root is used as-is."""
480 # Create a fake git repo structure
481 fake_git_root = tmp_path / "fake_repo"
482 fake_git_root.mkdir()
483 (fake_git_root / ".git").mkdir()
484 git_config = fake_git_root / ".git" / "config"
485 git_config.write_text(
486 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
487 )
488 git_head = fake_git_root / ".git" / "HEAD"
489 git_head.write_text("ref: refs/heads/main\n")
490 refs_dir = fake_git_root / ".git" / "refs" / "heads"
491 refs_dir.mkdir(parents=True)
492 (refs_dir / "main").write_text("abc123def456\n")
493
494 # Create source file
495 src_dir = fake_git_root / "src"
496 src_dir.mkdir()
497 src_file = src_dir / "test.c"
498 src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n")
499
500 # Create config in a different location using an ABSOLUTE git_root path
501 config_dir = tmp_path / "config"
502 config_dir.mkdir()
503 config_file = config_dir / "codelinks.toml"
504 # Use absolute path for both src_dir and git_root
505 config_content = f"""
506[codelinks.projects.test_project.source_discover]
507src_dir = "{src_dir.as_posix()}"
508gitignore = false
509
510[codelinks.projects.test_project.analyse]
511get_oneline_needs = true
512git_root = "{fake_git_root.as_posix()}"
513"""
514 config_file.write_text(config_content)
515
516 outdir = tmp_path / "output"
517 outdir.mkdir()
518
519 options = ["analyse", str(config_file), "--outdir", str(outdir)]
520 result = runner.invoke(app, options)
521
522 assert result.exit_code == 0, f"CLI failed: {result.stdout}"
523 output_path = outdir / "marked_content.json"
524 assert output_path.exists()
525
526 with output_path.open("r") as f:
527 marked_content = json.load(f)
528 # Verify the content was analysed using the correct git_root
529 assert len(marked_content["test_project"]) > 0
530
531
532# -- the TOML reader of `codelinks analyse` ----------------------------------------------
533
534
535def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]:
536 config = tmp_path / "cl.toml"
537 config.write_text(toml_text, encoding="utf-8")
538 result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)])
539 return result.exit_code, _normalize_output(result.output)
540
541
542def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None:
543 """The parser's own message reaches the user, not only "Failed to load"."""
544 broken = "[codelinks\n"
545 with pytest.raises(tomllib.TOMLDecodeError) as parse_error:
546 tomllib.loads(broken)
547
548 exit_code, output = _analyse(tmp_path, broken)
549
550 assert exit_code == 2
551 assert _normalize_output(str(parse_error.value)) in output
552 # ub-project's own words, not the catch-all's
553 assert "Failed to load" not in output
554
555
556@pytest.mark.parametrize(
557 ("value", "type_name"),
558 [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")],
559)
560def test_analyse_codelinks_not_a_table(
561 tmp_path: Path, value: str, type_name: str
562) -> None:
563 """``codelinks`` that is not a table is named as such, for a falsy value too."""
564 exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n")
565
566 assert exit_code == 2
567 assert f"[codelinks] must be a table, got {type_name}" in output
568 assert "No 'codelinks' section" not in output
569 assert "Failed to load" not in output
570
571
572@pytest.mark.parametrize(
573 "toml_text",
574 [
575 pytest.param("[codelinks]\n", id="empty-table"),
576 pytest.param("[needs]\nid_required = true\n", id="no-table"),
577 ],
578)
579def test_analyse_without_codelinks_configuration(
580 tmp_path: Path, toml_text: str
581) -> None:
582 """A missing or empty ``[codelinks]`` keeps today's words."""
583 exit_code, output = _analyse(tmp_path, toml_text)
584
585 assert exit_code == 2
586 assert "No 'codelinks' section found" in output
587
588
589def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None:
590 """``tomllib`` can fail with an exception ub-project does not wrap (a
591 ``RecursionError`` on a pathologically nested file): still a usage error, rc 2,
592 never a traceback."""
593 exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n")
594
595 # 1 would be the uncaught exception; typer reports a BadParameter as 2
596 assert exit_code == 2