[docs]  1# @Test suite for the warning on a marker above a preprocessor conditional, TEST_ABOVE_COND_1, test, [IMPL_EXTR_1]
  2"""A C or C++ marker that attachment rule 7 places on nothing -- alone on its line,
  3outside a function's body, directly above a preprocessor conditional's line -- is
  4warned about: one ``marker_above_conditional`` warning per marker, at its line, typed as
  5the marker (``analyse.rst``, rule 7). Every other marker on nothing, and every marker the
  6rule does not reach, stays silent.
  7
  8The analyser cases run ``SourceAnalyse`` on one file; the build cases build a copy of
  9``doc_test/need_id_refs`` (see ``test_need_id_refs``), whose ``index`` traces the whole of
 10project ``src``, with ``src/refs.cpp`` reduced to one reference that resolves.
 11"""
 12
 13import re
 14from pathlib import Path
 15
 16import pytest
 17from typer.testing import CliRunner
 18
 19from sphinx_codelinks.analyse.analyse import (
 20    ABOVE_CONDITIONAL_MSG,
 21    AnalyseWarning,
 22    SourceAnalyse,
 23)
 24from sphinx_codelinks.analyse.models import WarningSubTypeEnum
 25from sphinx_codelinks.cmd import app as cli
 26from sphinx_codelinks.config import PreprocessorConfig, SourceAnalyseConfig
 27from sphinx_needs_testkit import assert_no_warnings, build_warnings
 28
 29from .test_config_only_refs import NO_DIRECTIVE
 30from .test_need_id_refs import _SHOWS_WARNING_TYPES, _build, _json, _MakeApp, _project
 31
 32#: The message, as both implementations of the attachment rules emit it
 33MSG = (
 34    "directly above a preprocessor conditional, so attached to no entity: "
 35    "put the marker inside the arm, above the definition"
 36)
 37
 38SUB_TYPE = "marker_above_conditional"
 39
 40#: A one-arm conditional, the line right after the marker in most cases
 41ARM = "#if FEATURE_B\nvoid active() {}\n#endif\n"
 42
 43
 44def _analyse(
 45    tmp_path: Path, source: str, *, preprocessor: PreprocessorConfig | None = None
 46) -> SourceAnalyse:
 47    """Every extractor run over ``source``, written to ``x.cpp``."""
 48    path = tmp_path / "x.cpp"
 49    path.write_text(source, encoding="utf-8")
 50    config = SourceAnalyseConfig(
 51        src_files=[path],
 52        src_dir=tmp_path,
 53        get_oneline_needs=True,
 54        get_need_id_refs=True,
 55        get_multiline_needs=True,
 56        preprocessor=preprocessor,
 57    )
 58    analyse = SourceAnalyse(config)
 59    analyse.git_remote_url = None
 60    analyse.git_commit_rev = None
 61    analyse.run(log_summary=False)
 62    return analyse
 63
 64
 65def _above(analyse: SourceAnalyse) -> list[tuple[int, str]]:
 66    """The ``(lineno, type)`` of each ``marker_above_conditional`` warning, sorted."""
 67    return sorted(
 68        (warning.lineno, warning.type)
 69        for warning in analyse.warnings
 70        if warning.sub_type == SUB_TYPE
 71    )
 72
 73
 74def test_the_message_is_the_shared_one() -> None:
 75    """The text is a contract with the other implementation of the attachment rules,
 76    byte for byte, and the sub-type is a ``WarningSubTypeEnum`` member."""
 77    assert ABOVE_CONDITIONAL_MSG == MSG
 78    assert WarningSubTypeEnum.marker_above_conditional.value == SUB_TYPE
 79
 80
 81def test_a_one_line_need_warns_with_every_field(tmp_path: Path) -> None:
 82    """One warning, every field as the other analyser warnings write it: the file, the
 83    marker's 1-based line, the marker's kind, the sub-type and the shared message."""
 84    analyse = _analyse(
 85        tmp_path, "namespace lib {\n// @Above, IMPL_ABOVE, impl, []\n" + ARM + "}\n"
 86    )
 87
 88    assert analyse.warnings == [
 89        AnalyseWarning(str(tmp_path / "x.cpp"), 2, MSG, "need", SUB_TYPE)
 90    ]
 91    assert analyse.oneline_needs[0].scope is None
 92
 93
 94@pytest.mark.parametrize(
 95    ("source", "expected"),
 96    [
 97        pytest.param(
 98            "class Widget {\n    // @need-ids: REQ_A, REQ_B\n" + ARM + "};\n",
 99            [(2, "need-id-refs")],
100            id="need-id-reference",
101        ),
102        pytest.param(
103            "namespace lib {\n// @need impl: Above\n// :id: IMPL_ML\n// @endneed\n"
104            + ARM
105            + "}\n",
106            [(2, "multiline-need")],
107            id="multi-line-need-at-its-open-line",
108        ),
109        pytest.param(
110            "namespace lib {\n/*\n * @First, IMPL_A, impl, []\n"
111            " * @Second, IMPL_B, impl, []\n */\n" + ARM + "}\n",
112            [(3, "need"), (4, "need")],
113            id="two-markers-in-one-comment",
114        ),
115        pytest.param(
116            "namespace lib {\n// @First, IMPL_A, impl, []\n"
117            "// @need-ids: REQ_A\n" + ARM + "}\n",
118            [(2, "need"), (3, "need-id-refs")],
119            id="a-need-and-a-reference-in-one-comment-run",
120        ),
121        pytest.param(
122            "void first() {}\n// @At file level, IMPL_FILE, impl, []\n" + ARM,
123            [(2, "need")],
124            id="file-level",
125        ),
126        pytest.param(
127            "class Widget {\n#if FEATURE_B\n    void active() {}\n"
128            "    // @Above else, IMPL_ELSE, impl, []\n#else\n    void fallback() {}\n"
129            "#endif\n};\n",
130            [(4, "need")],
131            id="above-else",
132        ),
133    ],
134)
135def test_each_marker_rule_7_places_on_nothing_warns(
136    tmp_path: Path, source: str, expected: list[tuple[int, str]]
137) -> None:
138    """Every marker kind warns, at its own line, typed as itself: one warning per
139    marker, so a comment holding two gives two."""
140    assert _above(_analyse(tmp_path, source)) == expected
141
142
143@pytest.mark.parametrize(
144    "source",
145    [
146        pytest.param(
147            "namespace lib {\nvoid first() {}  // @Trailing, IMPL_T, impl, []\n"
148            + ARM
149            + "}\n",
150            id="trailing",
151        ),
152        pytest.param(
153            "void outer() {\n    // @In the body, IMPL_B, impl, []\n"
154            "#if FEATURE_B\n    fast();\n#endif\n}\n",
155            id="function-body",
156        ),
157        pytest.param(
158            "namespace lib {\n// @Blank line, IMPL_BL, impl, []\n\n" + ARM + "}\n",
159            id="blank-line-between",
160        ),
161        pytest.param(
162            "namespace lib {\n// @Above a define, IMPL_D, impl, []\n"
163            "#define FAST 1\nvoid active() {}\n}\n",
164            id="define-above",
165        ),
166        pytest.param(
167            "// @File header, IMPL_H, impl, []\n\n" + ARM, id="file-header-comment"
168        ),
169        pytest.param(
170            "void first() {}\n// @At the end, IMPL_E, impl, []\n",
171            id="file-level-with-nothing-after",
172        ),
173        pytest.param(
174            "namespace lib {\n#if FEATURE_B\n// @Inside the arm, IMPL_ARM, impl, []\n"
175            "void active() {}\n#endif\n}\n",
176            id="inside-the-arm",
177        ),
178    ],
179)
180def test_a_marker_rule_7_does_not_place_stays_silent(
181    tmp_path: Path, source: str
182) -> None:
183    """No warning where rule 7 does not decide: a trailing marker, one in a function's
184    body, one with a blank line or another directive between, and a marker on nothing
185    by another rule (a file header, nothing after it at file level)."""
186    analyse = _analyse(tmp_path, source)
187
188    assert analyse.all_marked_content
189    assert analyse.warnings == []
190
191
192def test_the_libclang_engine_never_warns(tmp_path: Path) -> None:
193    """The libclang engine attaches no marker to an entity, so rule 7 never decides
194    there: the marker is extracted, and nothing is warned."""
195    pytest.importorskip("clang.cindex")
196    analyse = _analyse(
197        tmp_path,
198        "namespace lib {\n// @Above, IMPL_ABOVE, impl, []\n" + ARM + "}\n",
199        preprocessor=PreprocessorConfig(defines=[]),
200    )
201
202    assert [need.need["id"] for need in analyse.oneline_needs] == ["IMPL_ABOVE"]
203    assert analyse.warnings == []
204
205
206def test_codelinks_analyse_prints_it(tmp_path: Path) -> None:
207    """The CLI prints it in the one shape every analyser warning takes."""
208    (tmp_path / "x.cpp").write_text(
209        "namespace lib {\n// @Above, IMPL_ABOVE, impl, []\n" + ARM + "}\n",
210        encoding="utf-8",
211    )
212    config = tmp_path / "ubproject.toml"
213    config.write_text(
214        "[codelinks.projects.p.source_discover]\n"
215        'src_dir = "./"\n'
216        'comment_type = "cpp"\n'
217        "[codelinks.projects.p.analyse]\n"
218        "get_oneline_needs = true\n",
219        encoding="utf-8",
220    )
221    outdir = tmp_path / "out"
222    outdir.mkdir()
223
224    result = CliRunner().invoke(cli, ["analyse", str(config), "--outdir", str(outdir)])
225
226    assert result.exit_code == 0, result.output
227    # rich wraps the console at its width, anywhere in a long path: compare without
228    # whitespace
229    expected = (
230        f"Analyse warning in {tmp_path.resolve() / 'x.cpp'}:2 - {SUB_TYPE}: {MSG}"
231    )
232    assert re.sub(r"\s+", "", expected) in re.sub(r"\s+", "", result.output)
233
234
235#: ``refs.cpp`` with nothing to warn about
236REFS = {"src/refs.cpp": "// @need-ids: REQ_001\nvoid implements_a() {}\n"}
237
238#: line 2 is a one-line need directly above a conditional in a namespace
239ABOVE = {"src/x.cpp": "namespace lib {\n// @Above, IMPL_ABOVE\n" + ARM + "}\n"}
240
241
242def test_the_build_warns_at_the_marker_s_line(
243    tmp_path: Path, make_app: _MakeApp
244) -> None:
245    """One warning at the file and line, the sub-type leading the message, typed
246    ``codelinks.marker_above_conditional``; the need is still created, on no entity."""
247    _project(tmp_path, files={**REFS, **ABOVE})
248    app = _build(tmp_path, make_app)
249
250    suffix = " [codelinks.marker_above_conditional]" if _SHOWS_WARNING_TYPES else ""
251    assert app.statuscode == 0
252    assert build_warnings(app) == [f"src/x.cpp:2: WARNING: {SUB_TYPE}: {MSG}{suffix}"]
253    assert "IMPL_ABOVE" in _json(app)["needs"]
254
255
256@pytest.mark.parametrize(
257    ("suppressed", "silenced"),
258    [
259        pytest.param(["codelinks.marker_above_conditional"], True, id="its-own"),
260        pytest.param(["codelinks.oneline"], False, id="oneline"),
261        pytest.param(["codelinks.multiline_need"], False, id="multiline-need"),
262    ],
263)
264def test_suppress_warnings_silences_it(
265    tmp_path: Path, make_app: _MakeApp, suppressed: list[str], silenced: bool
266) -> None:
267    """Its own ``suppress_warnings`` entry silences it, and ``codelinks.oneline`` and
268    ``codelinks.multiline_need`` do not: they are separate sub-types."""
269    _project(tmp_path, files={**REFS, **ABOVE})
270    app = _build(tmp_path, make_app, confoverrides={"suppress_warnings": suppressed})
271
272    if silenced:
273        assert_no_warnings(app)
274    else:
275        suffix = " [codelinks.marker_above_conditional]" if _SHOWS_WARNING_TYPES else ""
276        assert build_warnings(app) == [
277            f"src/x.cpp:2: WARNING: {SUB_TYPE}: {MSG}{suffix}"
278        ]
279    assert "IMPL_ABOVE" in _json(app)["needs"]
280
281
282def test_a_config_only_project_reports_none(tmp_path: Path, make_app: _MakeApp) -> None:
283    """A project no directive traces reports no analyser warning, this one included."""
284    _project(tmp_path, files={**REFS, **NO_DIRECTIVE, **ABOVE})
285    app = _build(tmp_path, make_app)
286
287    assert_no_warnings(app)