[docs] 1# @Test suite for the code hop of every need src-trace creates, TEST_SOURCE_HOPS_1, test, [IMPL_LNK_1]
2"""Where a need ``src-trace`` creates comes from: its ``code`` hop, then its page.
3
4Every need ``src-trace`` creates, one-line and multi-line, records innermost first the
5marker in its source file (a ``code`` hop: the file under the Sphinx source directory,
6the marker's 1-based lines, the entity it is attached to, outermost first, and the file's
7language) and the page the directive is written on. Its ``lineno`` is the line the
8directive is written on, after ``rst_prolog`` and in an included file too. The hops are
9provisional, and reach a ``needs.json`` only in the experimental format 2a1.
10"""
11
12from __future__ import annotations
13
14import dataclasses
15import io
16import json
17import os
18import pickle
19import re
20import sys
21from collections.abc import Callable
22from pathlib import Path
23from typing import Any
24
25import pytest
26from sphinx.testing.util import SphinxTestApp
27from sphinx.util.console import strip_colors
28
29from sphinx_codelinks.analyse.entity import name_chain
30from sphinx_needs.api import NeedItemSource, NeedSourceHop
31from sphinx_needs.data import SphinxNeedsData
32
33_MakeApp = Callable[..., SphinxTestApp]
34
35# --- the project ----------------------------------------------------------------------
36
37PY = """\
38# @Python file marker, IMPL_PY_FILE, impl
39
40class Shape:
41 # @Area of a shape, IMPL_PY_ONE, impl
42 def area(self):
43 def inner(): # @Inner helper, IMPL_PY_LOCALS, impl
44 pass
45
46
47# @need impl: Python block
48# :id: IMPL_PY_MULTI
49#
50# The body.
51# @endneed
52def compute():
53 pass
54"""
55
56#: the same method of the same class, in its class (here) and out of line (below)
57CPP_NESTED = """\
58namespace ns {
59class W {
60 public:
61 // @In-class f, IMPL_CPP_IN, impl
62 void f() {}
63};
64} // namespace ns
65
66/**
67 * @need impl: C++ block
68 * :id: IMPL_CPP_MULTI
69 *
70 * The body.
71 * @endneed
72 */
73int run() { return 0; }
74"""
75
76CPP_OUT = """\
77namespace ns {
78class W {
79 public:
80 void f();
81};
82} // namespace ns
83
84// @Out-of-line f, IMPL_CPP_OUT, impl
85void ns::W::f() {}
86"""
87
88C = """\
89// @C add, IMPL_C_ONE, impl
90int add(int a, int b) { return a + b; }
91
92/*
93 * @need impl: C block
94 * :id: IMPL_C_MULTI
95 *
96 * The body.
97 * @endneed
98 */
99int sub(int a, int b) { return a - b; }
100"""
101
102#: a file of another suffix, traced by a C++ project (``:file:``): the project's language
103FOREIGN = """\
104// @Foreign suffix, IMPL_FOREIGN, impl
105
106int helper() { return 1; }
107"""
108
109RS = """\
110pub trait Shape {
111 fn area(&self) -> f64;
112}
113
114pub struct Widget;
115
116impl Shape for Widget {
117 // @Widget area, IMPL_RS_TRAIT, impl
118 fn area(&self) -> f64 {
119 1.0
120 }
121 // @need impl: Rust trait block
122 // :id: IMPL_RS_TRAIT_MULTI
123 //
124 // The body.
125 // @endneed
126 fn perimeter(&self) -> f64 {
127 2.0
128 }
129}
130
131impl Widget {
132 // @need impl: Rust block
133 // :id: IMPL_RS_MULTI
134 //
135 // The body.
136 // @endneed
137 pub fn new() -> Self {
138 Widget
139 }
140}
141"""
142
143TS = """\
144export class Greeter {
145 // @Greet, IMPL_TS_ONE, impl
146 greet(name: string): string {
147 return name;
148 }
149}
150
151/**
152 * @need impl: TypeScript block
153 * :id: IMPL_TS_MULTI
154 *
155 * The body.
156 * @endneed
157 */
158export function farewell(): void {}
159"""
160
161#: a YAML entry has no name: the hop has a language and no semantic location
162YAML = """\
163service:
164 # @YAML entry, IMPL_YAML_ONE, impl
165 port: 8080
166 # @need impl: YAML block
167 # :id: IMPL_YAML_MULTI
168 #
169 # The body.
170 # @endneed
171 host: localhost
172"""
173
174#: inside the Sphinx source directory: a path without ``..``
175INSIDE = """\
176# @Inside the source directory, IMPL_INSIDE, impl
177def inside():
178 pass
179"""
180
181#: CRLF line ends: lines count as they do with LF
182CRLF = (
183 b"class K:\r\n"
184 b" # @CRLF method, IMPL_CRLF_ONE, impl\r\n"
185 b" def m(self):\r\n"
186 b" pass\r\n"
187 b"\r\n"
188 b"\r\n"
189 b"# @need impl: CRLF block\r\n"
190 b"# :id: IMPL_CRLF_MULTI\r\n"
191 b"#\r\n"
192 b"# The body.\r\n"
193 b"# @endneed\r\n"
194 b"def g():\r\n"
195 b" pass\r\n"
196)
197
198TOML = """\
199[codelinks.projects.py.source_discover]
200src_dir = "../src/py"
201comment_type = "python"
202
203[codelinks.projects.cpp.source_discover]
204src_dir = "../src/cpp"
205comment_type = "cpp"
206
207[codelinks.projects.rust.source_discover]
208src_dir = "../src/rust"
209comment_type = "rust"
210
211[codelinks.projects.ts.source_discover]
212src_dir = "../src/ts"
213comment_type = "ts"
214
215[codelinks.projects.yaml.source_discover]
216src_dir = "../src/yaml"
217comment_type = "yaml"
218
219[codelinks.projects.inside.source_discover]
220src_dir = "code"
221comment_type = "python"
222""" + "".join(
223 f"""
224[codelinks.projects.{project}.analyse]
225get_oneline_needs = true
226get_multiline_needs = true
227"""
228 for project in ("py", "cpp", "rust", "ts", "yaml", "inside")
229)
230
231CONF = """\
232extensions = ["sphinx_needs", "sphinx_codelinks"]
233exclude_patterns = ["_build"]
234needs_build_json = True
235"""
236
237INDEX = """\
238Hops
239====
240
241.. src-trace::
242 :project: py
243
244.. src-trace::
245 :project: cpp
246
247.. src-trace::
248 :project: cpp
249 :file: tool.py
250
251Languages
252---------
253
254.. src-trace::
255 :project: rust
256
257.. src-trace::
258 :project: ts
259
260.. src-trace::
261 :project: yaml
262
263.. src-trace::
264 :project: inside
265"""
266
267FILES: dict[str, str | bytes] = {
268 "docs/conf.py": CONF,
269 "docs/ubproject.toml": TOML,
270 "docs/index.rst": INDEX,
271 "docs/code/inside.py": INSIDE,
272 "src/py/shapes.py": PY,
273 "src/py/crlf.py": CRLF,
274 "src/cpp/nested.cpp": CPP_NESTED,
275 "src/cpp/outofline.cpp": CPP_OUT,
276 "src/cpp/calc.c": C,
277 "src/cpp/tool.py": FOREIGN,
278 "src/rust/lib.rs": RS,
279 "src/ts/greeter.ts": TS,
280 "src/yaml/service.yaml": YAML,
281}
282
283
284def _write(root: Path, files: dict[str, str | bytes | None]) -> Path:
285 """Write the project under ``root``; return its Sphinx source directory. A file whose
286 content is the same is left untouched (Sphinx compares modification times), and
287 ``None`` removes one."""
288 for name, content in files.items():
289 path = root / name
290 if content is None:
291 path.unlink(missing_ok=True)
292 continue
293 data = content.encode("utf-8") if isinstance(content, str) else content
294 path.parent.mkdir(parents=True, exist_ok=True)
295 if not path.exists() or path.read_bytes() != data:
296 path.write_bytes(data)
297 return root / "docs"
298
299
300def _build(
301 make_app: _MakeApp, srcdir: Path, *, fresh: bool = True
302) -> tuple[SphinxTestApp, list[str], list[str]]:
303 """Build; return the app, the documents read and the documents written."""
304 app = make_app("html", srcdir=srcdir, freshenv=fresh, warning=io.StringIO())
305 app.build()
306 status = strip_colors(app._status.getvalue())
307 read = re.findall(r"reading sources\.\.\. \[\s*\d+%\] (\S+)", status)
308 written = re.findall(r"writing output\.\.\. \[\s*\d+%\] (\S+)", status)
309 return app, read, written
310
311
312def _compact(hop: NeedSourceHop) -> str:
313 """One hop on one line: ``kind [document] root:path:start-end (language) ::
314 semantic``, as sphinx-needs' own hop snapshots print them."""
315 parts = [hop.kind]
316 if hop.document is not None:
317 parts.append(f"[{hop.document}]")
318 if (physical := hop.physical) is not None:
319 where = f"{physical.root}:{physical.path}"
320 if physical.start_line is not None:
321 where += f":{physical.start_line}"
322 if physical.end_line is not None:
323 where += f"-{physical.end_line}"
324 parts.append(where)
325 if hop.language is not None:
326 parts.append(f"({hop.language})")
327 if hop.origin is not None:
328 parts.append(f"origin={hop.origin}")
329 if hop.semantic is not None:
330 parts.append(
331 ":: "
332 + " > ".join(
333 f"{entry.kind} {entry.name!r}"
334 + "".join(f" {key}={value!r}" for key, value in entry.extras)
335 for entry in hop.semantic
336 )
337 )
338 return " ".join(parts)
339
340
341@pytest.fixture
342def built(make_app: _MakeApp, tmp_path: Path) -> SphinxTestApp:
343 app, _, _ = _build(make_app, _write(tmp_path, FILES))
344 return app
345
346
347def _hops(app: SphinxTestApp) -> dict[str, list[str]]:
348 return {
349 need_id: [_compact(hop) for hop in need.source.hops]
350 for need_id, need in SphinxNeedsData(app.env).get_needs_view().items()
351 }
352
353
354# --- the hops ---------------------------------------------------------------------------
355
356_HOPS = "section 'Hops'"
357_LANGUAGES = "section 'Hops' > section 'Languages'"
358
359#: Every need's hops, innermost first: the marker in its file, then the directive.
360EXPECTED_HOPS = {
361 # attached to nothing (the file): the language from the comment type, no semantic
362 "IMPL_PY_FILE": [
363 "code srcdir:../src/py/shapes.py:1-1 (python)",
364 f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
365 ],
366 "IMPL_PY_ONE": [
367 "code srcdir:../src/py/shapes.py:4-4 (python) :: class 'Shape' > method 'area'"
368 " qualified_name='Shape.area'",
369 f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
370 ],
371 # Python's `<locals>` is implied by the kinds: a method another part follows
372 "IMPL_PY_LOCALS": [
373 "code srcdir:../src/py/shapes.py:6-6 (python) :: class 'Shape' > "
374 "method 'area' > function 'inner'"
375 " qualified_name='Shape.area.<locals>.inner'",
376 f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
377 ],
378 # a multi-line need: its open line to its close line
379 "IMPL_PY_MULTI": [
380 "code srcdir:../src/py/shapes.py:10-14 (python) :: function 'compute'"
381 " qualified_name='compute'",
382 f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
383 ],
384 "IMPL_CRLF_ONE": [
385 "code srcdir:../src/py/crlf.py:2-2 (python) :: class 'K' > method 'm'"
386 " qualified_name='K.m'",
387 f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
388 ],
389 "IMPL_CRLF_MULTI": [
390 "code srcdir:../src/py/crlf.py:7-11 (python) :: function 'g'"
391 " qualified_name='g'",
392 f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
393 ],
394 # the pair: equal names, different kinds, carried as the analysis gives them
395 "IMPL_CPP_IN": [
396 "code srcdir:../src/cpp/nested.cpp:4-4 (cpp) :: namespace 'ns' > class 'W' > "
397 "method 'f'"
398 " qualified_name='ns::W::f'",
399 f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
400 ],
401 "IMPL_CPP_OUT": [
402 "code srcdir:../src/cpp/outofline.cpp:8-8 (cpp) :: scope 'ns' > class 'W' > "
403 "method 'f'"
404 " qualified_name='ns::W::f'",
405 f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
406 ],
407 "IMPL_CPP_MULTI": [
408 "code srcdir:../src/cpp/nested.cpp:10-14 (cpp) :: function 'run'"
409 " qualified_name='run'",
410 f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
411 ],
412 # a `.c` file is C, in a project of the cpp comment type
413 "IMPL_C_ONE": [
414 "code srcdir:../src/cpp/calc.c:1-1 (c) :: function 'add' qualified_name='add'",
415 f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
416 ],
417 "IMPL_C_MULTI": [
418 "code srcdir:../src/cpp/calc.c:5-9 (c) :: function 'sub' qualified_name='sub'",
419 f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
420 ],
421 # a `.py` file traced by a C++ project: the project's language, not the suffix's
422 "IMPL_FOREIGN": [
423 "code srcdir:../src/cpp/tool.py:1-1 (cpp)",
424 f"page [index] srcdir:index.rst:10-12 (.rst) :: {_HOPS}",
425 ],
426 # a trait impl: the trait is an extra of the `impl` part
427 "IMPL_RS_TRAIT": [
428 "code srcdir:../src/rust/lib.rs:8-8 (rust) :: impl 'Widget' trait='Shape' > "
429 "method 'area'"
430 " qualified_name='<Widget as Shape>::area'",
431 f"page [index] srcdir:index.rst:17-18 (.rst) :: {_LANGUAGES}",
432 ],
433 # a multi-line need in a trait impl: the trait too
434 "IMPL_RS_TRAIT_MULTI": [
435 "code srcdir:../src/rust/lib.rs:12-16 (rust) :: impl 'Widget' trait='Shape' > "
436 "method 'perimeter'"
437 " qualified_name='<Widget as Shape>::perimeter'",
438 f"page [index] srcdir:index.rst:17-18 (.rst) :: {_LANGUAGES}",
439 ],
440 "IMPL_RS_MULTI": [
441 "code srcdir:../src/rust/lib.rs:23-27 (rust) :: impl 'Widget' > method 'new'"
442 " qualified_name='Widget::new'",
443 f"page [index] srcdir:index.rst:17-18 (.rst) :: {_LANGUAGES}",
444 ],
445 "IMPL_TS_ONE": [
446 "code srcdir:../src/ts/greeter.ts:2-2 (typescript) :: class 'Greeter' > "
447 "method 'greet'"
448 " qualified_name='Greeter.greet'",
449 f"page [index] srcdir:index.rst:20-21 (.rst) :: {_LANGUAGES}",
450 ],
451 "IMPL_TS_MULTI": [
452 "code srcdir:../src/ts/greeter.ts:9-13 (typescript) :: function 'farewell'"
453 " qualified_name='farewell'",
454 f"page [index] srcdir:index.rst:20-21 (.rst) :: {_LANGUAGES}",
455 ],
456 # a YAML entry has no name: no semantic location
457 "IMPL_YAML_ONE": [
458 "code srcdir:../src/yaml/service.yaml:2-2 (yaml)",
459 f"page [index] srcdir:index.rst:23-24 (.rst) :: {_LANGUAGES}",
460 ],
461 "IMPL_YAML_MULTI": [
462 "code srcdir:../src/yaml/service.yaml:4-8 (yaml)",
463 f"page [index] srcdir:index.rst:23-24 (.rst) :: {_LANGUAGES}",
464 ],
465 # inside the source directory: no `..`
466 "IMPL_INSIDE": [
467 "code srcdir:code/inside.py:1-1 (python) :: function 'inside'"
468 " qualified_name='inside'",
469 f"page [index] srcdir:index.rst:26-27 (.rst) :: {_LANGUAGES}",
470 ],
471}
472
473
474@pytest.mark.parametrize("need_id", list(EXPECTED_HOPS))
475def test_every_need_records_its_code_hop(built: SphinxTestApp, need_id: str) -> None:
476 assert _hops(built)[need_id] == EXPECTED_HOPS[need_id]
477
478
479def test_the_project_traces_exactly_the_pinned_needs(built: SphinxTestApp) -> None:
480 assert sorted(_hops(built)) == sorted(EXPECTED_HOPS)
481
482
483def test_the_cpp_pair_has_one_name_chain_and_two_kind_sequences(
484 built: SphinxTestApp,
485) -> None:
486 """The hop carries the parts as given; identity is codelinks' ``name_chain``."""
487 needs = SphinxNeedsData(built.env).get_needs_view()
488 nested, out = (needs[i].source.hops[0] for i in ("IMPL_CPP_IN", "IMPL_CPP_OUT"))
489 assert [p.kind for p in nested.semantic or ()] == ["namespace", "class", "method"]
490 assert [p.kind for p in out.semantic or ()] == ["scope", "class", "method"]
491
492 def chain(hop: NeedSourceHop) -> Any:
493 parts: list[Any] = [
494 {"kind": p.kind, "name": p.name, **dict(p.extras)}
495 for p in hop.semantic or ()
496 ]
497 return name_chain(parts, hop.language or "")
498
499 assert chain(nested) == chain(out)
500
501
502#: The entity's qualified name as the analysis renders it (``scope.qualified_name``), by
503#: its file's language: ``::`` for C, C++ and Rust (a trait impl ``<Widget as Shape>``),
504#: ``.`` otherwise, and Python's ``<locals>``.
505QUALIFIED = {
506 "IMPL_PY_ONE": "Shape.area",
507 "IMPL_PY_LOCALS": "Shape.area.<locals>.inner",
508 "IMPL_PY_MULTI": "compute",
509 "IMPL_CRLF_ONE": "K.m",
510 "IMPL_CRLF_MULTI": "g",
511 "IMPL_CPP_IN": "ns::W::f",
512 "IMPL_CPP_OUT": "ns::W::f",
513 "IMPL_CPP_MULTI": "run",
514 "IMPL_C_ONE": "add",
515 "IMPL_C_MULTI": "sub",
516 "IMPL_RS_TRAIT": "<Widget as Shape>::area",
517 "IMPL_RS_TRAIT_MULTI": "<Widget as Shape>::perimeter",
518 "IMPL_RS_MULTI": "Widget::new",
519 "IMPL_TS_ONE": "Greeter.greet",
520 "IMPL_TS_MULTI": "farewell",
521 "IMPL_INSIDE": "inside",
522}
523
524
525def test_the_entity_carries_the_analysis_qualified_name(built: SphinxTestApp) -> None:
526 """The innermost entry of a code hop, the entity itself, carries the analysis'
527 rendering of its qualified name as a ``("qualified_name", ...)`` extra, after a
528 trait; no other entry has one, and a hop with no entity has no semantic location."""
529 needs = SphinxNeedsData(built.env).get_needs_view()
530
531 def qualified(need_id: str) -> list[str | None]:
532 return [
533 dict(entry.extras).get("qualified_name")
534 for entry in needs[need_id].source.hops[0].semantic or ()
535 ]
536
537 assert {need_id: qualified(need_id)[-1] for need_id in QUALIFIED} == QUALIFIED
538 for need_id in EXPECTED_HOPS:
539 names = qualified(need_id)
540 assert (names == []) == (need_id not in QUALIFIED), need_id
541 assert all(name is None for name in names[:-1]), need_id
542 impl, method = needs["IMPL_RS_TRAIT"].source.hops[0].semantic or ()
543 assert impl.extras == (("trait", "Shape"),)
544 assert method.extras == (("qualified_name", "<Widget as Shape>::area"),)
545
546
547def test_a_need_s_lineno_is_its_directive_s_line(built: SphinxTestApp) -> None:
548 """The line the directive is written on: the page hop's start line."""
549 needs = SphinxNeedsData(built.env).get_needs_view()
550 assert {need_id: needs[need_id]["lineno"] for need_id in EXPECTED_HOPS} == {
551 need_id: int(re.findall(r"index\.rst:(\d+)-", hops[-1])[0])
552 for need_id, hops in EXPECTED_HOPS.items()
553 }
554
555
556def test_a_need_s_body_is_anchored_at_its_directive_s_line(
557 built: SphinxTestApp,
558) -> None:
559 """``lineno_content`` is the directive's line, one-line and multi-line alike, not
560 the line after its block where ``from_directive`` puts it: a body parsed at the page
561 without a ``content_source`` is the marker's, and is anchored at the directive
562 (``test_a_fallback_on_a_page_of_a_third_parser_keeps_the_need``)."""
563 needs = SphinxNeedsData(built.env).get_needs_view()
564 assert {
565 need_id: (
566 needs[need_id].source.lineno_content,
567 needs[need_id].source.parser_lineno,
568 )
569 for need_id in (
570 "IMPL_PY_ONE",
571 "IMPL_PY_MULTI",
572 "IMPL_RS_TRAIT",
573 "IMPL_RS_MULTI",
574 )
575 } == {
576 "IMPL_PY_ONE": (4, 4),
577 "IMPL_PY_MULTI": (4, 4),
578 "IMPL_RS_TRAIT": (17, 17),
579 "IMPL_RS_MULTI": (17, 17),
580 }
581
582
583def test_no_hop_reaches_needs_json(built: SphinxTestApp) -> None:
584 text = Path(built.outdir, "needs.json").read_text(encoding="utf-8")
585 assert "semantic" not in text
586 assert "hops" not in text
587 assert "shapes.py" not in text
588
589
590def test_the_code_hop_reaches_a_2a1_needs_json(
591 make_app: _MakeApp, tmp_path: Path
592) -> None:
593 """Under sphinx-needs' experimental ``needs_json_format_version = "2a1"`` the hops
594 are the record's ``source``, the code hop first."""
595 conf = CONF + 'needs_json_format_version = "2a1"\n'
596 app, _, _ = _build(make_app, _write(tmp_path, {**FILES, "docs/conf.py": conf}))
597 data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
598 record = next(r for r in data["needs"] if r["id"] == "IMPL_PY_ONE")
599 assert record["source"][0] == {
600 "kind": "code",
601 "physical": {
602 "root": "srcdir",
603 "path": "../src/py/shapes.py",
604 "start_line": 4,
605 "end_line": 4,
606 },
607 "semantic": [
608 {"kind": "class", "name": "Shape"},
609 {
610 "kind": "method",
611 "name": "area",
612 "extras": {"qualified_name": "Shape.area"},
613 },
614 ],
615 "language": "python",
616 }
617 assert [hop["kind"] for hop in record["source"]] == ["code", "page"]
618
619
620def test_a_need_after_rst_prolog_is_at_its_line_in_the_file(
621 make_app: _MakeApp, tmp_path: Path
622) -> None:
623 """``rst_prolog`` shifts the parser's count, not the file's lines."""
624 conf = CONF + 'rst_prolog = ".. |a| replace:: A\\n.. |b| replace:: B\\n"\n'
625 app, _, _ = _build(make_app, _write(tmp_path, {**FILES, "docs/conf.py": conf}))
626 needs = SphinxNeedsData(app.env).get_needs_view()
627 assert needs["IMPL_PY_ONE"]["lineno"] == 4
628 assert needs["IMPL_RS_TRAIT"]["lineno"] == 17
629 # the body's anchor stays in the parser's count, which the prolog shifts
630 source = needs["IMPL_PY_ONE"].source
631 assert source.lineno_content == source.parser_lineno == 7
632 assert _hops(app)["IMPL_RS_TRAIT"] == EXPECTED_HOPS["IMPL_RS_TRAIT"]
633
634
635def test_a_need_in_an_included_file_is_at_its_line_there(
636 make_app: _MakeApp, tmp_path: Path
637) -> None:
638 page = "Hops\n====\n\nProse.\n\n.. include:: traced.rst.txt\n"
639 included = "Some prose.\n\n.. src-trace::\n :project: rust\n"
640 app, _, _ = _build(
641 make_app,
642 _write(
643 tmp_path,
644 {**FILES, "docs/index.rst": page, "docs/traced.rst.txt": included},
645 ),
646 )
647 needs = SphinxNeedsData(app.env).get_needs_view()
648 # line 3 of ``traced.rst.txt``, the included file, as a need directive records it
649 # (the parser's count, 10 before, was a line neither file has the directive on)
650 assert needs["IMPL_RS_TRAIT"]["lineno"] == 3
651 assert _hops(app)["IMPL_RS_TRAIT"] == [
652 "code srcdir:../src/rust/lib.rs:8-8 (rust) :: impl 'Widget' trait='Shape' > "
653 "method 'area' qualified_name='<Widget as Shape>::area'",
654 "file srcdir:traced.rst.txt:3-4 (.txt)",
655 "page [index] srcdir:index.rst (.rst) :: section 'Hops'",
656 ]
657
658
659# --- the code hop's path: Sphinx-Needs' rule, computed here ----------------------------
660
661_SRCDIR = os.path.join(os.sep, "project", "docs")
662
663#: ``(srcdir, path)``: what ``physical_for`` and the package's helper are both asked.
664PATH_SHAPES = {
665 "inside": (_SRCDIR, os.path.join(_SRCDIR, "code", "a.py")),
666 "outside": (_SRCDIR, os.path.join(os.sep, "project", "src", "a.py")),
667 "far outside": (_SRCDIR, os.path.join(os.sep, "elsewhere", "a.py")),
668 "relative to the working directory": ("docs", os.path.join("src", "a.py")),
669 "the source directory itself": (_SRCDIR, _SRCDIR),
670 "dotted segments": (_SRCDIR, os.path.join(_SRCDIR, "a", "..", "a.py")),
671}
672if sys.platform == "win32":
673 PATH_SHAPES["backslashes"] = ("C:\\project\\docs", "C:\\project\\src\\a.py")
674 PATH_SHAPES["forward slashes"] = ("C:/project/docs", "C:/project/docs/a.py")
675 PATH_SHAPES["another drive"] = ("C:\\project\\docs", "D:\\src\\a.py")
676
677
678@pytest.mark.parametrize("shape", list(PATH_SHAPES))
679def test_the_code_path_is_sphinx_needs_rule(shape: str) -> None:
680 from sphinx_codelinks.sphinx_extension.directives.src_trace import _code_physical
681 from sphinx_needs._need_source import physical_for
682
683 srcdir, path = PATH_SHAPES[shape]
684 assert _code_physical(srcdir, path) == physical_for(srcdir, path)
685 assert _code_physical(srcdir, path, 3, 7) == physical_for(srcdir, path, 3, 7)
686
687
688def test_the_code_path_under_a_symlinked_directory_is_sphinx_needs_rule(
689 tmp_path: Path,
690) -> None:
691 from sphinx_codelinks.sphinx_extension.directives.src_trace import _code_physical
692 from sphinx_needs._need_source import physical_for
693
694 (tmp_path / "real").mkdir()
695 (tmp_path / "real" / "a.py").write_text(INSIDE, encoding="utf-8")
696 (tmp_path / "docs").mkdir()
697 try:
698 (tmp_path / "docs" / "linked").symlink_to(
699 tmp_path / "real", target_is_directory=True
700 )
701 except OSError:
702 pytest.skip("no symlinks here")
703 for srcdir, path in [
704 (tmp_path / "docs", tmp_path / "docs" / "linked" / "a.py"),
705 (tmp_path / "docs" / "linked", tmp_path / "real" / "a.py"),
706 ]:
707 assert _code_physical(srcdir, path) == physical_for(srcdir, path)
708
709
710# --- incremental builds: each equals a fresh build -------------------------------------
711
712#: a page whose need the traced one links to: it shows the traced need as a back link
713OTHER = """\
714Other
715=====
716
717.. req:: A requirement
718 :id: REQ_1
719"""
720
721PY_LINKED = PY.replace(
722 "# @Area of a shape, IMPL_PY_ONE, impl",
723 "# @Area of a shape, IMPL_PY_ONE, impl, [REQ_1]",
724)
725INCREMENTAL = {
726 **FILES,
727 "docs/index.rst": INDEX + "\n.. toctree::\n\n other\n",
728 "docs/other.rst": OTHER,
729 "src/py/shapes.py": PY_LINKED,
730}
731
732#: ``scenario: {file: new content}``, over :data:`INCREMENTAL`
733SCENARIOS: dict[str, dict[str, str | bytes | None]] = {
734 # three lines above every marker of the file: the needs only move
735 "marker-moved": {"src/py/shapes.py": "\n\n\n" + PY_LINKED},
736 # the entity a marker is attached to is renamed: its hop's semantic changes
737 "entity-renamed": {
738 "src/py/shapes.py": PY_LINKED.replace("def area", "def surface")
739 },
740 # the marker itself is edited
741 "source-edited": {
742 "src/py/shapes.py": PY_LINKED.replace("Area of a shape", "Area of any shape")
743 },
744}
745
746
747def _pages(outdir: Path) -> dict[str, str]:
748 """The body of every page, the part a need can show up in."""
749 body = re.compile(
750 r'<div class="body" role="main">(.*?)<div class="sphinxsidebar"', re.DOTALL
751 )
752 pages = {}
753 for path in sorted(outdir.glob("*.html")):
754 if path.stem in {"genindex", "search", "permalink"}:
755 continue
756 match = body.search(path.read_text(encoding="utf-8"))
757 assert match is not None, path
758 pages[path.stem] = re.sub(r"SNCB-[0-9a-f]+", "SNCB-", match.group(1))
759 return pages
760
761
762def _sources(app: SphinxTestApp) -> dict[str, Any]:
763 """Every need's lineno and hops (paths are relative, so two projects compare)."""
764 return {
765 need_id: (need["lineno"], need.source.hops)
766 for need_id, need in SphinxNeedsData(app.env).get_needs_view().items()
767 }
768
769
770def _needs_json(app: SphinxTestApp) -> dict[str, Any]:
771 data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
772 return data["versions"][data["current_version"]]["needs"]
773
774
775def _assert_equivalent(
776 make_app: _MakeApp, tmp_path: Path, scenario: str
777) -> tuple[SphinxTestApp, list[str]]:
778 """Build, edit, build incrementally, and compare with a fresh build of the edit.
779
780 :return: The incremental app, and the documents it wrote.
781 """
782 after = {**INCREMENTAL, **SCENARIOS[scenario]}
783 incremental = _write(tmp_path / "incremental", INCREMENTAL)
784 _build(make_app, incremental)
785 _write(tmp_path / "incremental", after)
786 app, _, written = _build(make_app, incremental, fresh=False)
787
788 fresh_app, _, _ = _build(make_app, _write(tmp_path / "fresh", after))
789 got, expected = _pages(Path(app.outdir)), _pages(Path(fresh_app.outdir))
790 stale = sorted(name for name in expected if got.get(name) != expected[name])
791 assert stale == [], f"pages not re-written: {stale} (wrote {written})"
792 assert _needs_json(app) == _needs_json(fresh_app)
793 assert _sources(app) == _sources(fresh_app)
794 return app, written
795
796
797def test_a_marker_that_moves_rewrites_only_its_own_page(
798 make_app: _MakeApp, tmp_path: Path
799) -> None:
800 """Its needs only moved: the page showing one as a back link is not re-written."""
801 app, written = _assert_equivalent(make_app, tmp_path, "marker-moved")
802 assert written == ["index"]
803 assert _hops(app)["IMPL_PY_ONE"][0].startswith(
804 "code srcdir:../src/py/shapes.py:7-7 "
805 )
806
807
808def test_a_renamed_entity_rewrites_the_pages_showing_its_need(
809 make_app: _MakeApp, tmp_path: Path
810) -> None:
811 app, written = _assert_equivalent(make_app, tmp_path, "entity-renamed")
812 assert sorted(written) == ["index", "other"]
813 assert _hops(app)["IMPL_PY_ONE"][0].endswith(
814 ":: class 'Shape' > method 'surface' qualified_name='Shape.surface'"
815 )
816
817
818def test_an_edited_source_file_equals_a_fresh_build(
819 make_app: _MakeApp, tmp_path: Path
820) -> None:
821 _, written = _assert_equivalent(make_app, tmp_path, "source-edited")
822 assert sorted(written) == ["index", "other"]
823
824
825# --- the upgrade ------------------------------------------------------------------------
826
827
828def test_an_environment_pickled_at_env_version_1_is_read_again(
829 make_app: _MakeApp, tmp_path: Path
830) -> None:
831 """Version 1 recorded each need on its page alone, at the parser's line, with no
832 code hop. Nothing in the sources or the configuration changed: ``env_version`` 2 is
833 what has the build read the page again here, rather than keep those records."""
834 srcdir = _write(tmp_path, FILES)
835 app, _, _ = _build(make_app, srcdir)
836 pickled = Path(app.doctreedir, "environment.pickle")
837 env = pickle.loads(pickled.read_bytes())
838 env.version["sphinx_codelinks"] = 1
839 # what version 1 recorded: the page, at the directive's line, and nothing else
840 for need in env._needs_all_needs.values():
841 need._source = NeedItemSource.on_page(need["docname"], lineno=need["lineno"])
842 pickled.write_bytes(pickle.dumps(env, pickle.HIGHEST_PROTOCOL))
843
844 app, read, _ = _build(make_app, srcdir, fresh=False)
845 assert read == ["index"]
846 assert _hops(app) == EXPECTED_HOPS
847
848
849def test_an_environment_pickled_at_env_version_2_is_read_again(
850 make_app: _MakeApp, tmp_path: Path
851) -> None:
852 """Version 2 recorded each need's code hop without the entity's qualified name.
853 Nothing in the sources or the configuration changed: ``env_version`` 3 is what has
854 the build read the page again here, rather than keep those records."""
855 srcdir = _write(tmp_path, FILES)
856 app, _, _ = _build(make_app, srcdir)
857 pickled = Path(app.doctreedir, "environment.pickle")
858 env = pickle.loads(pickled.read_bytes())
859 env.version["sphinx_codelinks"] = 2
860 # what version 2 recorded: the code hop's entries without the qualified name
861 for need in env._needs_all_needs.values():
862 code, *rest = need._source.hops
863 entries = tuple(
864 dataclasses.replace(
865 entry,
866 extras=tuple(e for e in entry.extras if e[0] != "qualified_name"),
867 )
868 for entry in code.semantic or ()
869 )
870 hops = (
871 dataclasses.replace(code, semantic=entries if code.semantic else None),
872 *rest,
873 )
874 object.__setattr__(need._source, "hops", hops)
875 pickled.write_bytes(pickle.dumps(env, pickle.HIGHEST_PROTOCOL))
876
877 app, read, _ = _build(make_app, srcdir, fresh=False)
878 assert read == ["index"]
879 assert _hops(app) == EXPECTED_HOPS
880
881
882def test_an_entity_s_qualified_name_follows_its_trait(tmp_path: Path) -> None:
883 """A marker attached to a Rust trait impl itself: the entity's extras are its
884 ``trait`` and then its ``qualified_name``, in that order (extras are compared in
885 order)."""
886 from types import SimpleNamespace
887
888 from sphinx_codelinks.source_discover.config import CommentType
889 from sphinx_codelinks.sphinx_extension.directives.src_trace import code_hop
890
891 marker = SimpleNamespace(
892 source_map={"start": {"row": 6}, "end": {"row": 6}},
893 scope={
894 "language": "rust",
895 "qualified_name": "<Widget as Shape>",
896 "semantic": [{"kind": "impl", "name": "Widget", "trait": "Shape"}],
897 },
898 )
899 hop = code_hop(
900 marker, # ty: ignore[invalid-argument-type]
901 tmp_path / "lib.rs",
902 tmp_path,
903 CommentType.rust,
904 )
905 (impl,) = hop.semantic or ()
906 assert impl.extras == (("trait", "Shape"), ("qualified_name", "<Widget as Shape>"))