[docs] 1# @Test suite for tree-sitter parsing utilities and language support, TEST_LANG_1, test, [IMPL_LANG_1, IMPL_EXTR_1]
2import shutil
3import subprocess
4from pathlib import Path
5
6import pytest
7from tree_sitter import Node as TreeSitterNode
8from tree_sitter import Parser, Query
9
10from sphinx_codelinks.analyse import utils
11from sphinx_codelinks.source_discover.config import COMMENT_FILETYPE, CommentType
12
13
14# The fixtures build the parsers the way analysis does, through init_tree_sitter.
15@pytest.fixture(scope="session")
16def init_cpp_tree_sitter() -> tuple[Parser, Query]:
17 return utils.init_tree_sitter(CommentType.cpp)
18
19
20@pytest.fixture(scope="session")
21def init_python_tree_sitter() -> tuple[Parser, Query]:
22 return utils.init_tree_sitter(CommentType.python)
23
24
25@pytest.fixture(scope="session")
26def init_csharp_tree_sitter() -> tuple[Parser, Query]:
27 return utils.init_tree_sitter(CommentType.cs)
28
29
30@pytest.fixture(scope="session")
31def init_yaml_tree_sitter() -> tuple[Parser, Query]:
32 return utils.init_tree_sitter(CommentType.yaml)
33
34
35@pytest.fixture(scope="session")
36def init_rust_tree_sitter() -> tuple[Parser, Query]:
37 return utils.init_tree_sitter(CommentType.rust)
38
39
40@pytest.fixture(scope="session")
41def init_typescript_tree_sitter() -> tuple[Parser, Query]:
42 # A .tsx file: the TSX grammar, which the JSX fixtures below need
43 return utils.init_tree_sitter(CommentType.ts, Path("fixture.tsx"))
44
45
46@pytest.fixture(scope="session")
47def init_go_tree_sitter() -> tuple[Parser, Query]:
48 return utils.init_tree_sitter(CommentType.go)
49
50
51@pytest.fixture(scope="session")
52def init_jsonc_tree_sitter() -> tuple[Parser, Query]:
53 return utils.init_tree_sitter(CommentType.jsonc)
54
55
56@pytest.fixture(scope="session")
57def init_bash_tree_sitter() -> tuple[Parser, Query]:
58 return utils.init_tree_sitter(CommentType.bash)
59
60
61def test_every_comment_type_is_wired():
62 """A comment type is a ``CommentType`` member plus an entry in each of several
63 tables; a member one of them misses fails here, rather than as a ``KeyError`` in
64 discovery or as no scope at all."""
65 members = set(CommentType)
66 assert set(COMMENT_FILETYPE) == {member.value for member in members}
67 # YAML and JSONC bind to data structures, not scopes (find_associated_scope)
68 assert set(utils.SCOPE_NODE_TYPES) == members - {
69 CommentType.yaml,
70 CommentType.jsonc,
71 }
72 assert set(utils.WRAPPER_TYPES) <= set(utils.ENTITY_NODE_TYPES)
73 assert set(utils.TRIVIA_TYPES) <= set(utils.ENTITY_NODE_TYPES)
74 for comment_type, predicates in utils.SCOPE_PREDICATES.items():
75 assert set(predicates) <= utils.SCOPE_NODE_TYPES[comment_type]
76 for member in members:
77 utils.init_tree_sitter(member)
78
79
80@pytest.mark.parametrize(
81 ("code", "result"),
82 [
83 (
84 b"""
85 // @req-id: need_001
86 void dummy_func1(){
87 }
88 """,
89 "void dummy_func1()",
90 ),
91 (
92 b"""
93 void dummy_func2(){
94 }
95 // @req-id: need_001
96 void dummy_func1(){
97 }
98 """,
99 "void dummy_func1()",
100 ),
101 (
102 b"""
103 void dummy_func1(){
104 a = 1;
105 /* @req-id: need_001 */
106 }
107 """,
108 "void dummy_func1()",
109 ),
110 (
111 b"""
112 void dummy_func1(){
113 // @req-id: need_001
114 a = 1;
115 }
116 void dummy_func2(){
117 }
118 """,
119 "void dummy_func1()",
120 ),
121 ],
122)
123def test_find_associated_scope_cpp(code, result, init_cpp_tree_sitter):
124 parser, query = init_cpp_tree_sitter
125 comments = utils.extract_comments(code, parser, query)
126 node: TreeSitterNode | None = utils.find_associated_scope(
127 comments[0], CommentType.cpp
128 )
129 assert node
130 assert node.text
131 func_def = node.text.decode("utf-8")
132 assert result in func_def
133
134
135#: What :func:`_attached_to` gives for a marker rule 7 attaches to nothing
136ABOVE_CONDITIONAL = utils.Unattached.above_conditional
137
138
139def _attached_to(
140 source: str, parser: Parser, query: Query
141) -> tuple[str, str, str] | utils.Unattached | None:
142 """What the comment holding ``marker`` in the C++ ``source`` is attached to: the
143 entity's node kind, its first line and the mechanism; :data:`ABOVE_CONDITIONAL`
144 for nothing by rule 7, ``None`` for nothing by any other rule."""
145 comments = utils.extract_comments(source.encode(), parser, query) or []
146 marker = next(c for c in comments if b"marker" in (c.text or b""))
147 association = utils.associate(marker, CommentType.cpp)
148 if association is None or isinstance(association, utils.Unattached):
149 return association
150 first_line = (association.node.text or b"").decode().splitlines()[0]
151 return association.node.type, first_line, association.mechanism
152
153
154#: The conditionals a marker can stand directly above, each the body of a namespace or a
155#: class with the marker as its first line
156CONDITIONAL_ARMS = {
157 "one-arm": "#if FEATURE_B\nvoid active() {}\n#endif\n",
158 "ifdef": "#ifdef FEATURE_B\nvoid active() {}\n#endif\n",
159 "ifndef": "#ifndef FEATURE_B\nvoid active() {}\n#endif\n",
160 "else-different-functions": (
161 "#if FEATURE_B\nvoid active() {}\n#else\nvoid fallback() {}\n#endif\n"
162 ),
163 "else-same-function": (
164 "#if FEATURE_B\nvoid active() { fast(); }\n"
165 "#else\nvoid active() { slow(); }\n#endif\n"
166 ),
167 "elif": (
168 "#if FEATURE_A\nvoid a() {}\n#elif FEATURE_B\nvoid b() {}\n"
169 "#else\nvoid c() {}\n#endif\n"
170 ),
171 "declaration-only": "#if FEATURE_B\nvoid declared();\n#endif\n",
172}
173
174#: The marker as the last line of an arm, directly above the next directive of the
175#: same conditional
176ARM_ENDINGS = {
177 "above-else": (
178 "#if FEATURE_B\nvoid active() {}\n// marker\n#else\nvoid fallback() {}\n#endif\n"
179 ),
180 "above-elif": (
181 "#if FEATURE_A\nvoid a() {}\n// marker\n#elif FEATURE_B\nvoid b() {}\n#endif\n"
182 ),
183 "above-elifdef": (
184 "#ifdef FEATURE_A\nvoid a() {}\n// marker\n#elifdef FEATURE_B\nvoid b() {}\n"
185 "#endif\n"
186 ),
187 "above-endif": "#if FEATURE_B\nvoid active() {}\n// marker\n#endif\n",
188 "above-endif-after-else": (
189 "#if FEATURE_B\nvoid a() {}\n#else\nvoid b() {}\n// marker\n#endif\n"
190 ),
191}
192
193CONTAINERS = {
194 "namespace": ("namespace lib {\n", "}\n"),
195 "class": ("class Widget {\n", "};\n"),
196}
197
198
199@pytest.mark.parametrize("container", list(CONTAINERS))
200@pytest.mark.parametrize(
201 "body",
202 [
203 *(
204 pytest.param(f"// marker\n{arms}", id=name)
205 for name, arms in CONDITIONAL_ARMS.items()
206 ),
207 *(pytest.param(body, id=name) for name, body in ARM_ENDINGS.items()),
208 pytest.param("/// marker\n" + CONDITIONAL_ARMS["one-arm"], id="doc-comment"),
209 pytest.param(
210 "// marker\n// and its second line\n" + CONDITIONAL_ARMS["one-arm"],
211 id="comment-run",
212 ),
213 pytest.param(
214 "/* marker\n */\n" + CONDITIONAL_ARMS["one-arm"], id="block-comment"
215 ),
216 ],
217)
218def test_marker_directly_above_a_conditional_is_on_nothing(
219 container: str, body: str, init_cpp_tree_sitter: tuple[Parser, Query]
220) -> None:
221 """A marker directly above a preprocessor conditional's line, in a namespace or a
222 class body, is attached to nothing, as clang attaches it: it would otherwise trace
223 every unit of the container, whichever arm is compiled. ``#elif``, ``#else`` and
224 ``#endif`` count as the opening directives do, and other comments may stand between
225 when no blank line does."""
226 head, tail = CONTAINERS[container]
227
228 assert _attached_to(head + body + tail, *init_cpp_tree_sitter) is ABOVE_CONDITIONAL
229
230
231@pytest.mark.parametrize(
232 "source",
233 [
234 pytest.param(
235 "struct S {\n// marker\n" + CONDITIONAL_ARMS["one-arm"] + "};\n",
236 id="struct",
237 ),
238 pytest.param(
239 "union U {\n// marker\n#if FEATURE_B\nint a;\n#endif\nfloat b;\n};\n",
240 id="union",
241 ),
242 pytest.param(
243 "enum E {\n// marker\n#if FEATURE_B\nA,\n#endif\nB,\n};\n", id="enum"
244 ),
245 pytest.param(
246 'namespace lib {\nextern "C" {\n// marker\n'
247 + CONDITIONAL_ARMS["declaration-only"]
248 + "}\n}\n",
249 id="extern-c-block-in-a-namespace",
250 ),
251 ],
252)
253def test_marker_directly_above_a_conditional_is_on_nothing_in_any_body(
254 source: str, init_cpp_tree_sitter: tuple[Parser, Query]
255) -> None:
256 """Not only in a namespace or class body: anywhere outside a function's body, a
257 struct, union or enum body and an ``extern "C"`` block included, a marker directly
258 above a conditional is attached to nothing, where the struct, union or enum, or
259 the namespace around the block, held it."""
260 assert _attached_to(source, *init_cpp_tree_sitter) is ABOVE_CONDITIONAL
261
262
263@pytest.mark.parametrize(
264 ("source", "expected"),
265 [
266 pytest.param(
267 "// marker\n" + CONDITIONAL_ARMS["one-arm"],
268 ABOVE_CONDITIONAL,
269 id="file-level-first",
270 ),
271 pytest.param(
272 "void first() {}\n// marker\n" + CONDITIONAL_ARMS["one-arm"],
273 ABOVE_CONDITIONAL,
274 id="file-level-after-an-item",
275 ),
276 pytest.param(
277 "// marker\n\n" + CONDITIONAL_ARMS["one-arm"], None, id="file-header"
278 ),
279 pytest.param("void first() {}\n// marker\n", None, id="file-level-at-the-end"),
280 pytest.param(
281 "void outer() {\n// marker\n#if FEATURE_B\nfast();\n#endif\n}\n",
282 ("function_definition", "void outer() {", "enclosing"),
283 id="function-body",
284 ),
285 pytest.param(
286 "namespace lib {\nvoid outer() {\n// marker\n#ifdef FEATURE_B\nfast();\n"
287 "#endif\n}\n}\n",
288 ("function_definition", "void outer() {", "enclosing"),
289 id="function-body-in-a-namespace",
290 ),
291 pytest.param(
292 "namespace lib {\nvoid first() {} // marker\n"
293 + CONDITIONAL_ARMS["one-arm"]
294 + "}\n",
295 ("function_definition", "void first() {}", "trailing"),
296 id="trailing",
297 ),
298 pytest.param(
299 "namespace lib {\n// marker\n\n" + CONDITIONAL_ARMS["one-arm"] + "}\n",
300 ("namespace_definition", "namespace lib {", "enclosing"),
301 id="blank-line-namespace",
302 ),
303 pytest.param(
304 "class Widget {\n// marker\n\n" + CONDITIONAL_ARMS["one-arm"] + "};\n",
305 ("class_specifier", "class Widget {", "enclosing"),
306 id="blank-line-class",
307 ),
308 pytest.param(
309 "namespace lib {\n// marker\n// and its second line\n\n"
310 + CONDITIONAL_ARMS["one-arm"]
311 + "}\n",
312 ("namespace_definition", "namespace lib {", "enclosing"),
313 id="blank-line-after-a-comment-run",
314 ),
315 pytest.param(
316 "namespace lib {\n#if FEATURE_B\nvoid active() {}\n// marker\n\n#endif\n}\n",
317 ("namespace_definition", "namespace lib {", "enclosing"),
318 id="blank-line-above-endif",
319 ),
320 pytest.param(
321 "namespace lib {\n// marker\n#define FAST 1\nvoid active() {}\n}\n",
322 ("namespace_definition", "namespace lib {", "enclosing"),
323 id="above-define",
324 ),
325 pytest.param(
326 'namespace lib {\n// marker\n#include "fast.h"\nvoid active() {}\n}\n',
327 ("namespace_definition", "namespace lib {", "enclosing"),
328 id="above-include",
329 ),
330 pytest.param(
331 "class Widget {\n// marker\n#pragma pack(1)\nvoid active() {}\n};\n",
332 ("class_specifier", "class Widget {", "enclosing"),
333 id="above-pragma",
334 ),
335 pytest.param(
336 "namespace lib {\n#if FEATURE_B\n// marker\nvoid active() {}\n#endif\n}\n",
337 ("function_definition", "void active() {}", "leading"),
338 id="inside-the-arm",
339 ),
340 pytest.param(
341 "void outer() {\nstruct L {\n// marker\n"
342 + CONDITIONAL_ARMS["one-arm"]
343 + "};\n}\n",
344 ("struct_specifier", "struct L {", "enclosing"),
345 id="class-local-to-a-function",
346 ),
347 pytest.param(
348 "namespace lib {\nauto l = []() {\n// marker\n#if FEATURE_B\nactive();\n"
349 "#endif\n};\nvoid after() {}\n}\n",
350 ("namespace_definition", "namespace lib {", "enclosing"),
351 id="lambda-in-a-namespace",
352 ),
353 pytest.param(
354 "namespace lib {\n// marker\n%:if FEATURE_B\nvoid active() {}\n%:endif\n}\n",
355 ("namespace_definition", "namespace lib {", "enclosing"),
356 id="digraph-if",
357 ),
358 pytest.param(
359 "namespace lib {\n#if FEATURE_B\nvoid active() {}\n// marker\n%:endif\n}\n",
360 ("namespace_definition", "namespace lib {", "enclosing"),
361 id="digraph-endif",
362 ),
363 pytest.param(
364 "namespace lib {\n#define F(x) \\\n /* marker */ \\\n # if x\n"
365 "void active() {}\n}\n",
366 ("namespace_definition", "namespace lib {", "enclosing"),
367 id="define-continuation",
368 ),
369 ],
370)
371def test_marker_near_a_conditional_keeps_its_attachment(
372 source: str,
373 expected: tuple[str, str, str] | utils.Unattached | None,
374 init_cpp_tree_sitter: tuple[Parser, Query],
375) -> None:
376 """Around a preprocessor conditional, every other marker keeps the attachment it
377 has: at file level it is on nothing anyway, by rule 7 when directly above the
378 conditional and by another rule (a file header, nothing after it) otherwise; in a function's body it is the
379 function's; a trailing marker is its row's entity's; a blank line between makes it
380 a comment on the namespace or class as a whole; any other directive (``#define``,
381 ``#include``, ``#pragma``) breaks the attachment to the next entity as any code
382 does; and inside the arm, the marker leads the definition, which is the remedy.
383
384 A lambda's body and a class defined in a function are a function's body too. And
385 the directive is read as the grammar reads it: a digraph (``%:if``) is no
386 conditional to it, nor is a ``# if`` line that continues a ``#define``."""
387 assert _attached_to(source, *init_cpp_tree_sitter) == expected
388
389
390@pytest.mark.parametrize(
391 ("code", "result"),
392 [
393 (
394 b"""
395 def dummy_func1():
396 # @req-id: need_001
397 pass
398 """,
399 "def dummy_func1()",
400 ),
401 (
402 b"""
403 def dummy_func1():
404 # @req-id: need_002
405 def dummy_func2():
406 pass
407 pass
408 """,
409 "def dummy_func2()",
410 ),
411 (
412 b"""
413 def dummy_func1():
414 '''@req-id: need_002'''
415 def nested_dummy_func():
416 pass
417 pass
418 """,
419 "def dummy_func1()",
420 ),
421 (
422 b"""
423 def dummy_func1():
424 def nested_dummy_func():
425 '''@req-id: need_002'''
426 pass
427 pass
428 """,
429 "def nested_dummy_func()",
430 ),
431 (
432 b"""
433 def dummy_func1():
434 def nested_dummy_func():
435 # @req-id: need_002
436 pass
437 pass
438 """,
439 "def nested_dummy_func()",
440 ),
441 (
442 b"""
443 def dummy_func1():
444 def nested_dummy_func():
445 pass
446 # @req-id: need_002
447 pass
448 """,
449 "def dummy_func1()",
450 ),
451 ],
452)
453def test_find_associated_scope_python(code, result, init_python_tree_sitter):
454 parser, query = init_python_tree_sitter
455 comments = utils.extract_comments(code, parser, query)
456 node: TreeSitterNode | None = utils.find_associated_scope(
457 comments[0], CommentType.python
458 )
459 assert node
460 assert node.text
461 func_def = node.text.decode("utf-8")
462 assert func_def.startswith(result)
463
464
465@pytest.mark.parametrize(
466 ("code", "result"),
467 [
468 (
469 b"""
470 // @req-id: need_001
471 public class DummyClass1
472 {
473 }
474 """,
475 "public class DummyClass1",
476 ),
477 (
478 b"""
479 public class DummyClass2
480 {
481 // @req-id: need_001
482 public void DummyFunc2()
483 {
484 }
485 }
486 """,
487 "public void DummyFunc2",
488 ),
489 (
490 b"""
491 public class DummyClass3
492 {
493 // @req-id: need_001
494 public string Property1 { get; set; }
495 }
496 """,
497 "public string Property1",
498 ),
499 ],
500)
501def test_find_associated_scope_csharp(code, result, init_csharp_tree_sitter):
502 parser, query = init_csharp_tree_sitter
503 comments = utils.extract_comments(code, parser, query)
504 node: TreeSitterNode | None = utils.find_associated_scope(
505 comments[0], CommentType.cs
506 )
507 assert node
508 assert node.text
509 func_def = node.text.decode("utf-8")
510 assert func_def.startswith(result)
511
512
513@pytest.mark.parametrize(
514 ("code", "result"),
515 [
516 (
517 b"""
518 # @req-id: need_001
519 database:
520 host: localhost
521 port: 5432
522 """,
523 "database:",
524 ),
525 (
526 b"""
527 services:
528 web:
529 # @req-id: need_002
530 image: nginx:latest
531 ports:
532 - "80:80"
533 """,
534 "image: nginx:latest",
535 ),
536 (
537 b"""
538 # @req-id: need_003
539 version: "3.8"
540 services:
541 app:
542 build: .
543 """,
544 "version:",
545 ),
546 (
547 b"""
548 items:
549 # @req-id: need_004
550 - name: item1
551 value: test
552 - name: item2
553 value: test2
554 """,
555 "- name: item1",
556 ),
557 ],
558)
559def test_find_associated_scope_yaml(code, result, init_yaml_tree_sitter):
560 parser, query = init_yaml_tree_sitter
561 comments = utils.extract_comments(code, parser, query)
562 node: TreeSitterNode | None = utils.find_associated_scope(
563 comments[0], CommentType.yaml
564 )
565 assert node
566 assert node.text
567 yaml_structure = node.text.decode("utf-8")
568 assert result in yaml_structure
569
570
571@pytest.mark.parametrize(
572 ("code", "result"),
573 [
574 (
575 b"""
576 // @req-id: need_001
577 fn dummy_func1() {
578 }
579 """,
580 "fn dummy_func1()",
581 ),
582 (
583 b"""
584 fn dummy_func2() {
585 }
586 // @req-id: need_001
587 fn dummy_func1() {
588 }
589 """,
590 "fn dummy_func1()",
591 ),
592 (
593 b"""
594 fn dummy_func1() {
595 let a = 1;
596 /* @req-id: need_001 */
597 }
598 """,
599 "fn dummy_func1()",
600 ),
601 (
602 b"""
603 fn dummy_func1() {
604 // @req-id: need_001
605 let a = 1;
606 }
607 fn dummy_func2() {
608 }
609 """,
610 "fn dummy_func1()",
611 ),
612 (
613 b"""
614 /// @req-id: need_001
615 fn dummy_func1() {
616 }
617 """,
618 "fn dummy_func1()",
619 ),
620 (
621 b"""
622 struct MyStruct {
623 // @req-id: need_001
624 field: i32,
625 }
626 """,
627 "struct MyStruct",
628 ),
629 ],
630)
631def test_find_associated_scope_rust(code, result, init_rust_tree_sitter):
632 parser, query = init_rust_tree_sitter
633 comments = utils.extract_comments(code, parser, query)
634 node: TreeSitterNode | None = utils.find_associated_scope(
635 comments[0], CommentType.rust
636 )
637 assert node
638 assert node.text
639 rust_def = node.text.decode("utf-8")
640 assert result in rust_def
641
642
643@pytest.mark.parametrize(
644 ("code", "result"),
645 [
646 # leading comment is associated with the following key/value pair
647 (
648 b'{\n // @req-id: need_001\n "alpha": 1\n}\n',
649 '"alpha": 1',
650 ),
651 # inline comment is associated with the array item on the same row
652 (
653 b'{\n "items": [\n "first", // @req-id: need_001\n "second"\n ]\n}\n',
654 '"first"',
655 ),
656 # inline comment is associated with the pair on the same row
657 (
658 b'{\n "alpha": 1, // @req-id: need_001\n "beta": 2\n}\n',
659 '"alpha": 1',
660 ),
661 # block comment is associated with the following pair
662 (
663 b'{\n /* @req-id: need_001 */\n "beta": 2\n}\n',
664 '"beta": 2',
665 ),
666 # trailing comment falls back to the enclosing object
667 (
668 b'{\n "alpha": 1\n // @req-id: need_001\n}\n',
669 '"alpha"',
670 ),
671 ],
672)
673def test_find_associated_scope_jsonc(code, result, init_jsonc_tree_sitter):
674 parser, query = init_jsonc_tree_sitter
675 comments = utils.extract_comments(code, parser, query)
676 node: TreeSitterNode | None = utils.find_associated_scope(
677 comments[0], CommentType.jsonc
678 )
679 assert node
680 assert node.text
681 jsonc_structure = node.text.decode("utf-8")
682 assert result in jsonc_structure
683
684
685@pytest.mark.parametrize(
686 ("code", "result"),
687 [
688 # comment above a POSIX-style function definition
689 (
690 b"""
691 # @req-id: need_001
692 greet() {
693 echo hi
694 }
695 """,
696 "greet()",
697 ),
698 # comment above the `function` keyword form
699 (
700 b"""
701 # @req-id: need_002
702 function greet {
703 echo hi
704 }
705 """,
706 "function greet",
707 ),
708 # comment inside a function body falls back to the enclosing function
709 (
710 b"""
711 greet() {
712 # @req-id: need_003
713 echo hi
714 }
715 """,
716 "greet()",
717 ),
718 ],
719)
720def test_find_associated_scope_bash(code, result, init_bash_tree_sitter):
721 parser, query = init_bash_tree_sitter
722 comments = utils.extract_comments(code, parser, query)
723 node: TreeSitterNode | None = utils.find_associated_scope(
724 comments[0], CommentType.bash
725 )
726 assert node
727 assert node.text
728 func_def = node.text.decode("utf-8")
729 assert func_def.startswith(result)
730
731
732@pytest.mark.parametrize(
733 ("code", "result"),
734 [
735 (
736 b"""
737 // @req-id: need_001
738 function dummyFunc1() {
739 }
740 """,
741 "function dummyFunc1()",
742 ),
743 (
744 b"""
745 class DummyClass {
746 // @req-id: need_001
747 method1() {
748 }
749 }
750 """,
751 "method1()",
752 ),
753 # leading comment on an exported function must descend into
754 # export_statement, not resolve to no scope
755 (
756 b"""
757 // @req-id: need_001
758 export function dummyFunc1() {
759 }
760 """,
761 "function dummyFunc1()",
762 ),
763 # leading comment on an exported class
764 (
765 b"""
766 // @req-id: need_001
767 export class DummyClass {
768 }
769 """,
770 "class DummyClass",
771 ),
772 # leading comment on an exported const arrow function
773 (
774 b"""
775 // @req-id: need_001
776 export const dummyFunc1 = () => {
777 };
778 """,
779 "dummyFunc1",
780 ),
781 # arrow-function const still resolves to itself
782 (
783 b"""
784 // @req-id: need_001
785 const dummyFunc1 = () => {
786 };
787 """,
788 "dummyFunc1",
789 ),
790 # JSX-returning component parses cleanly and resolves scope (.tsx content)
791 (
792 b"""
793 // @req-id: need_001
794 export function Button() {
795 return <button>Click me</button>;
796 }
797 """,
798 "function Button()",
799 ),
800 ],
801)
802def test_find_associated_scope_typescript(code, result, init_typescript_tree_sitter):
803 parser, query = init_typescript_tree_sitter
804 comments = utils.extract_comments(code, parser, query)
805 node: TreeSitterNode | None = utils.find_associated_scope(
806 comments[0], CommentType.ts
807 )
808 assert node
809 assert node.text
810 ts_def = node.text.decode("utf-8")
811 assert result in ts_def
812
813
814@pytest.mark.parametrize(
815 ("code", "result"),
816 [
817 (
818 b"""
819 def dummy_func1():
820 # @req-id: need_001
821 pass
822 """,
823 "def dummy_func1()",
824 ),
825 (
826 b"""
827 def dummy_func1():
828 '''@req-id: need_001'''
829 pass
830 """,
831 "def dummy_func1()",
832 ),
833 (
834 b"""
835 def dummy_func1():
836 def nested_dummy_func1():
837 '''@req-id: need_001'''
838 pass
839 pass
840 """,
841 "def nested_dummy_func1()",
842 ),
843 (
844 b"""
845 def dummy_func1():
846 '''@req-id: need_001'''
847 def nested_dummy_func1():
848 pass
849 pass
850 """,
851 "def dummy_func1()",
852 ),
853 ],
854)
855def test_find_enclosing_scope_python(code, result, init_python_tree_sitter):
856 parser, query = init_python_tree_sitter
857 comments = utils.extract_comments(code, parser, query)
858 node: TreeSitterNode | None = utils.find_enclosing_scope(
859 comments[0], CommentType.python
860 )
861 assert node
862 assert node.text
863 func_def = node.text.decode("utf-8")
864 assert result in func_def
865
866
867@pytest.mark.parametrize(
868 ("code", "result"),
869 [
870 (
871 b"""
872 # @req-id: need_001
873 def dummy_func1():
874 pass
875 """,
876 "def dummy_func1()",
877 ),
878 (
879 b"""
880 # @req-id: need_001
881 # @req-id: need_002
882 def dummy_func1():
883 pass
884 """,
885 "def dummy_func1()",
886 ),
887 ],
888)
889def test_find_next_scope_python(code, result, init_python_tree_sitter):
890 parser, query = init_python_tree_sitter
891 comments = utils.extract_comments(code, parser, query)
892 node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.python)
893 assert node
894 assert node.text
895 func_def = node.text.decode("utf-8")
896 assert result in func_def
897
898
899@pytest.mark.parametrize(
900 ("code", "result"),
901 [
902 (
903 b"""
904 // @req-id: need_001
905 void dummy_func1(){
906 }
907 """,
908 "void dummy_func1()",
909 ),
910 (
911 b"""
912 /* @req-id: need_001 */
913 void dummy_func1(){
914 }
915 """,
916 "void dummy_func1()",
917 ),
918 ],
919)
920def test_find_next_scope_cpp(code, result, init_cpp_tree_sitter):
921 parser, query = init_cpp_tree_sitter
922 comments = utils.extract_comments(code, parser, query)
923 node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.cpp)
924 assert node
925 assert node.text
926 func_def = node.text.decode("utf-8")
927 assert result in func_def
928
929
930@pytest.mark.parametrize(
931 ("code", "result"),
932 [
933 (
934 b"""
935 // @req-id: need_001
936 public class DummyClass1
937 {
938 }
939 """,
940 "public class DummyClass1",
941 ),
942 (
943 b"""
944
945 public class DummyClass1
946 {
947 /* @req-id: need_001 */
948 /* @req-id: need_002 */
949 public void DummyFunc1()
950 {
951 }
952 }
953 """,
954 "public void DummyFunc1",
955 ),
956 ],
957)
958def test_find_next_scope_csharp(code, result, init_csharp_tree_sitter):
959 parser, query = init_csharp_tree_sitter
960 comments = utils.extract_comments(code, parser, query)
961 node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.cs)
962 assert node
963 assert node.text
964 func_def = node.text.decode("utf-8")
965 assert result in func_def
966
967
968@pytest.mark.parametrize(
969 ("code", "result"),
970 [
971 (
972 b"""
973 // @req-id: need_001
974 function dummyFunc1() {
975 }
976 """,
977 "function dummyFunc1()",
978 ),
979 (
980 b"""
981 // @req-id: need_001
982 export function dummyFunc1() {
983 }
984 """,
985 "function dummyFunc1()",
986 ),
987 ],
988)
989def test_find_next_scope_typescript(code, result, init_typescript_tree_sitter):
990 parser, query = init_typescript_tree_sitter
991 comments = utils.extract_comments(code, parser, query)
992 node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.ts)
993 assert node
994 assert node.text
995 func_def = node.text.decode("utf-8")
996 assert result in func_def
997
998
999def test_a_plain_const_breaks_adjacency_typescript(init_typescript_tree_sitter):
1000 """A plain (non-function) const between the comment and a function is code, not
1001 trivia: the comment is attached to neither, here to the file."""
1002 code = b"""
1003 // @req-id: need_001
1004 const helperFlag = true;
1005 function dummyFunc1() {
1006 }
1007 """
1008 parser, query = init_typescript_tree_sitter
1009 comments = utils.extract_comments(code, parser, query)
1010 assert comments
1011 assert utils.find_next_scope(comments[0], CommentType.ts) is None
1012 assert utils.find_associated_scope(comments[0], CommentType.ts) is None
1013
1014
1015def test_find_associated_scope_typescript_jsx_no_parse_error(
1016 init_typescript_tree_sitter,
1017):
1018 """A JSX-returning component must parse cleanly under the TSX grammar."""
1019 code = b"""
1020 // @req-id: need_001
1021 export function Button() {
1022 return <button>Click me</button>;
1023 }
1024 """
1025 parser, _ = init_typescript_tree_sitter
1026 tree = parser.parse(code)
1027 assert not tree.root_node.has_error
1028
1029
1030@pytest.mark.parametrize(
1031 ("name", "grammar"),
1032 [
1033 ("a.ts", "typescript"),
1034 ("a.mts", "typescript"),
1035 ("a.cts", "typescript"),
1036 ("A.TS", "typescript"),
1037 ("a.Mts", "typescript"),
1038 # a declaration file's suffix is ``.ts``: TypeScript, as any ``.ts`` file
1039 ("a.d.ts", "typescript"),
1040 ("a.tsx", "tsx"),
1041 ("a.js", "tsx"),
1042 ("a.jsx", "tsx"),
1043 ("a.mjs", "tsx"),
1044 ("a.cjs", "tsx"),
1045 ],
1046)
1047def test_ts_grammar_key(name: str, grammar: str) -> None:
1048 """TypeScript's own suffixes, in any case, get the TypeScript grammar, where a
1049 ``<T>x`` cast is legal; every other suffix of the family gets TSX."""
1050 assert utils.ts_grammar_key(Path(name)) == grammar
1051
1052
1053def test_typescript_ts_suffix_recovers_markers_around_angle_bracket_cast():
1054 """A ``.ts`` file must use the plain TypeScript grammar, not TSX.
1055
1056 ``<string>x`` is a legacy angle-bracket type assertion: valid TypeScript
1057 syntax, but JSX syntax under the TSX grammar. There it parses as a
1058 ``jsx_opening_element`` and swallows the rest of the file into a single
1059 ``jsx_text`` node, silently dropping every marker after it (``has_error``
1060 is also set). ``init_tree_sitter`` must pick the plain TypeScript grammar
1061 for a ``.ts`` path, via ``ts_grammar_key``, so markers both above and
1062 below the cast all survive.
1063 """
1064 code = b"""// @Top, IMPL_TOP
1065const v = <string>x;
1066// @Bottom, IMPL_BOTTOM
1067const y = 2;
1068// @Third, IMPL_THIRD
1069"""
1070 parser, query = utils.init_tree_sitter(CommentType.ts, Path("dummy.ts"))
1071 tree = parser.parse(code)
1072 assert not tree.root_node.has_error
1073
1074 comments = utils.extract_comments(code, parser, query)
1075 assert comments is not None
1076 comments.sort(key=lambda node: node.start_point.row)
1077 texts = [node.text.decode("utf-8") for node in comments if node.text]
1078 assert texts == [
1079 "// @Top, IMPL_TOP",
1080 "// @Bottom, IMPL_BOTTOM",
1081 "// @Third, IMPL_THIRD",
1082 ]
1083
1084
1085@pytest.mark.parametrize(
1086 ("code", "result"),
1087 [
1088 (
1089 b"""
1090 void dummy_func1(){
1091 // @req-id: need_001
1092 }
1093 """,
1094 "void dummy_func1()",
1095 ),
1096 (
1097 b"""
1098 void dummy_func1(){
1099 /* @req-id: need_001 */
1100 }
1101 """,
1102 "void dummy_func1()",
1103 ),
1104 ],
1105)
1106def test_find_enclosing_scope_cpp(code, result, init_cpp_tree_sitter):
1107 parser, query = init_cpp_tree_sitter
1108 comments = utils.extract_comments(code, parser, query)
1109 node: TreeSitterNode | None = utils.find_enclosing_scope(
1110 comments[0], CommentType.cpp
1111 )
1112 assert node
1113 assert node.text
1114 func_def = node.text.decode("utf-8")
1115 assert result in func_def
1116
1117
1118@pytest.mark.parametrize(
1119 ("code", "result"),
1120 [
1121 (
1122 b"""
1123 public class DummyClass1
1124 {
1125 // @req-id: need_001
1126 }
1127 """,
1128 "public class DummyClass1",
1129 ),
1130 (
1131 b"""
1132 public class DummyClass1
1133 {
1134 public void DummyFunc1()
1135 {
1136 /* @req-id: need_001 */
1137 }
1138 }
1139 """,
1140 "public void DummyFunc1()",
1141 ),
1142 (
1143 b"""
1144 public class DummyClass1
1145 {
1146 public string DummyProperty1
1147 {
1148 get
1149 {
1150 /* @req-id: need_001 */
1151 return "dummy";
1152 }
1153 }
1154 }
1155 """,
1156 "public string DummyProperty1",
1157 ),
1158 ],
1159)
1160def test_find_enclosing_scope_csharp(code, result, init_csharp_tree_sitter):
1161 parser, query = init_csharp_tree_sitter
1162 comments = utils.extract_comments(code, parser, query)
1163 node: TreeSitterNode | None = utils.find_enclosing_scope(
1164 comments[0], CommentType.cs
1165 )
1166 assert node
1167 assert node.text
1168 func_def = node.text.decode("utf-8")
1169 assert result in func_def
1170
1171
1172@pytest.mark.parametrize(
1173 ("code", "num_comments", "result"),
1174 [
1175 (
1176 b"""
1177 // @req-id: need_001
1178 void dummy_func1(){
1179 }
1180 """,
1181 1,
1182 "// @req-id: need_001",
1183 ),
1184 (
1185 b"""
1186 void dummy_func1(){
1187 // @req-id: need_001
1188 }
1189 """,
1190 1,
1191 "// @req-id: need_001",
1192 ),
1193 (
1194 b"""
1195 /* @req-id: need_001 */
1196 void dummy_func1(){
1197 }
1198 """,
1199 1,
1200 "/* @req-id: need_001 */",
1201 ),
1202 (
1203 b"""
1204 // @req-id: need_001
1205 //
1206 //
1207 void dummy_func1(){
1208 }
1209 """,
1210 3,
1211 "// @req-id: need_001",
1212 ),
1213 ],
1214)
1215def test_cpp_comment(code, num_comments, result, init_cpp_tree_sitter):
1216 parser, query = init_cpp_tree_sitter
1217 comments = utils.extract_comments(code, parser, query)
1218 assert len(comments) == num_comments
1219 comments.sort(key=lambda x: x.start_point.row)
1220 assert comments[0].text
1221 assert comments[0].text.decode("utf-8") == result
1222
1223
1224@pytest.mark.parametrize(
1225 ("code", "num_comments", "result"),
1226 [
1227 (
1228 b"""
1229 # @req-id: need_001
1230 def dummy_func1():
1231 pass
1232 """,
1233 1,
1234 "# @req-id: need_001",
1235 ),
1236 (
1237 b"""
1238 def dummy_func1():
1239 # @req-id: need_001
1240 pass
1241 """,
1242 1,
1243 "# @req-id: need_001",
1244 ),
1245 (
1246 b"""
1247 # single line comment
1248 # @req-id: need_001
1249 def dummy_func1():
1250 pass
1251 """,
1252 2,
1253 "# single line comment",
1254 ),
1255 (
1256 b"""
1257 def dummy_func1():
1258 '''
1259 @req-id: need_001
1260 '''
1261 pass
1262 """,
1263 1,
1264 "'''\n @req-id: need_001\n '''",
1265 ),
1266 (
1267 b"""
1268 def dummy_func1():
1269 text = '''@req-id: need_001, need_002, this docstring shall not be extracted as comment'''
1270 # @req-id: need_001
1271 pass
1272 """,
1273 1,
1274 "# @req-id: need_001",
1275 ),
1276 ],
1277)
1278def test_python_comment(code, num_comments, result, init_python_tree_sitter):
1279 parser, query = init_python_tree_sitter
1280 comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1281 comments.sort(key=lambda x: x.start_point.row)
1282 assert len(comments) == num_comments
1283 assert comments[0].text
1284 assert comments[0].text.decode("utf-8") == result
1285
1286
1287@pytest.mark.parametrize(
1288 ("code", "num_comments", "result"),
1289 [
1290 (
1291 b"""
1292 // @req-id: need_001
1293 void DummyFunc1(){
1294 }
1295 """,
1296 1,
1297 "// @req-id: need_001",
1298 ),
1299 (
1300 b"""
1301 void DummyFunc1(){
1302 // @req-id: need_001
1303 }
1304 """,
1305 1,
1306 "// @req-id: need_001",
1307 ),
1308 (
1309 b"""
1310 /* @req-id: need_001 */
1311 void DummyFunc1(){
1312 }
1313 """,
1314 1,
1315 "/* @req-id: need_001 */",
1316 ),
1317 (
1318 b"""
1319 // @req-id: need_001
1320 //
1321 //
1322 void DummyFunc1(){
1323 }
1324 """,
1325 3,
1326 "// @req-id: need_001",
1327 ),
1328 ],
1329)
1330def test_csharp_comment(code, num_comments, result, init_csharp_tree_sitter):
1331 parser, query = init_csharp_tree_sitter
1332 comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1333 comments.sort(key=lambda x: x.start_point.row)
1334 assert len(comments) == num_comments
1335 assert comments[0].text
1336 assert comments[0].text.decode("utf-8") == result
1337
1338
1339@pytest.mark.parametrize(
1340 ("code", "num_comments", "result"),
1341 [
1342 (
1343 b"""
1344 // @req-id: need_001
1345 function dummyFunc1() {
1346 }
1347 """,
1348 1,
1349 "// @req-id: need_001",
1350 ),
1351 (
1352 b"""
1353 /* @req-id: need_001 */
1354 const value = 1;
1355 """,
1356 1,
1357 "/* @req-id: need_001 */",
1358 ),
1359 ],
1360)
1361def test_typescript_comment(code, num_comments, result, init_typescript_tree_sitter):
1362 parser, query = init_typescript_tree_sitter
1363 comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1364 comments.sort(key=lambda x: x.start_point.row)
1365 assert len(comments) == num_comments
1366 assert comments[0].text
1367 assert comments[0].text.decode("utf-8") == result
1368
1369
1370@pytest.mark.parametrize(
1371 ("code", "num_comments", "result"),
1372 [
1373 (
1374 b"""
1375 # @req-id: need_001
1376 database:
1377 host: localhost
1378 """,
1379 1,
1380 "# @req-id: need_001",
1381 ),
1382 (
1383 b"""
1384 services:
1385 web:
1386 # @req-id: need_001
1387 image: nginx:latest
1388 """,
1389 1,
1390 "# @req-id: need_001",
1391 ),
1392 (
1393 b"""
1394 # Top level comment
1395 # @req-id: need_001
1396 version: "3.8"
1397 """,
1398 2,
1399 "# Top level comment",
1400 ),
1401 ],
1402)
1403def test_yaml_comment(code, num_comments, result, init_yaml_tree_sitter):
1404 parser, query = init_yaml_tree_sitter
1405 comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1406 comments.sort(key=lambda x: x.start_point.row)
1407 assert len(comments) == num_comments
1408 assert comments[0].text
1409 assert comments[0].text.decode("utf-8") == result
1410
1411
1412@pytest.mark.parametrize(
1413 ("code", "num_comments", "result"),
1414 [
1415 (
1416 b"""
1417 // @req-id: need_001
1418 func dummyFunc1() {
1419 }
1420 """,
1421 1,
1422 "// @req-id: need_001",
1423 ),
1424 (
1425 b"""
1426 func dummyFunc1() {
1427 // @req-id: need_001
1428 }
1429 """,
1430 1,
1431 "// @req-id: need_001",
1432 ),
1433 (
1434 b"""
1435 /* @req-id: need_001 */
1436 func dummyFunc1() {
1437 }
1438 """,
1439 1,
1440 "/* @req-id: need_001 */",
1441 ),
1442 (
1443 b"""
1444 // @req-id: need_001
1445 //
1446 //
1447 func dummyFunc1() {
1448 }
1449 """,
1450 3,
1451 "// @req-id: need_001",
1452 ),
1453 ],
1454)
1455def test_go_comment(code, num_comments, result, init_go_tree_sitter):
1456 parser, query = init_go_tree_sitter
1457 comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1458 comments.sort(key=lambda x: x.start_point.row)
1459 assert len(comments) == num_comments
1460 assert comments[0].text
1461 assert comments[0].text.decode("utf-8") == result
1462
1463
1464@pytest.mark.parametrize(
1465 ("code", "result"),
1466 [
1467 (
1468 b"""
1469 // @req-id: need_001
1470 func dummyFunc1() {
1471 }
1472 """,
1473 "func dummyFunc1()",
1474 ),
1475 (
1476 b"""
1477 func dummyFunc2() {
1478 }
1479 // @req-id: need_001
1480 func dummyFunc1() {
1481 }
1482 """,
1483 "func dummyFunc1()",
1484 ),
1485 (
1486 b"""
1487 /* @req-id: need_001 */
1488 func dummyFunc1() {
1489 }
1490 """,
1491 "func dummyFunc1()",
1492 ),
1493 ],
1494)
1495def test_find_associated_scope_go(code, result, init_go_tree_sitter):
1496 parser, query = init_go_tree_sitter
1497 comments = utils.extract_comments(code, parser, query)
1498 node: TreeSitterNode | None = utils.find_associated_scope(
1499 comments[0], CommentType.go
1500 )
1501 assert node
1502 assert node.text
1503 go_def = node.text.decode("utf-8")
1504 assert result in go_def
1505
1506
1507@pytest.mark.parametrize(
1508 ("code", "result"),
1509 [
1510 (
1511 b"""
1512 // @req-id: need_001
1513 func dummyFunc1() {
1514 }
1515 """,
1516 "func dummyFunc1()",
1517 ),
1518 (
1519 b"""
1520 // @req-id: need_001
1521 type DummyStruct struct {
1522 Field int
1523 }
1524 """,
1525 "type DummyStruct struct",
1526 ),
1527 ],
1528)
1529def test_find_next_scope_go(code, result, init_go_tree_sitter):
1530 parser, query = init_go_tree_sitter
1531 comments = utils.extract_comments(code, parser, query)
1532 node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.go)
1533 assert node
1534 assert node.text
1535 go_def = node.text.decode("utf-8")
1536 assert result in go_def
1537
1538
1539@pytest.mark.parametrize(
1540 ("code", "result"),
1541 [
1542 (
1543 b"""
1544 func dummyFunc1() {
1545 // @req-id: need_001
1546 }
1547 """,
1548 "func dummyFunc1()",
1549 ),
1550 (
1551 b"""
1552 func dummyFunc1() {
1553 /* @req-id: need_001 */
1554 }
1555 """,
1556 "func dummyFunc1()",
1557 ),
1558 ],
1559)
1560def test_find_enclosing_scope_go(code, result, init_go_tree_sitter):
1561 parser, query = init_go_tree_sitter
1562 comments = utils.extract_comments(code, parser, query)
1563 node: TreeSitterNode | None = utils.find_enclosing_scope(
1564 comments[0], CommentType.go
1565 )
1566 assert node
1567 assert node.text
1568 go_def = node.text.decode("utf-8")
1569 assert result in go_def
1570
1571
1572@pytest.mark.parametrize(
1573 ("git_url", "rev", "project_path", "filepath", "lineno", "result"),
1574 [
1575 (
1576 "git@github.com:useblocks/sphinx-codelinks.git",
1577 "beef1234",
1578 Path(__file__).parent.parent,
1579 # ABSOLUTE, and under `project_path`. `form_https_url` resolves the
1580 # filepath with `.absolute()`, i.e. against the CWD, so a relative one
1581 # here only agrees with `project_path` when pytest happens to run from
1582 # the package directory -- which it does not when the whole workspace's
1583 # suite is invoked from the repository root
1584 Path(__file__).parent.parent / "example" / "to" / "here",
1585 3,
1586 "https://github.com/useblocks/sphinx-codelinks/blob/beef1234/example/to/here#L3",
1587 )
1588 ],
1589)
1590def test_form_https_url(
1591 git_url, rev, project_path, filepath, lineno, result
1592): # need to have these args
1593 url = utils.form_https_url(git_url, rev, project_path, filepath, lineno=lineno)
1594 assert url == result
1595
1596
1597def get_git_path() -> str:
1598 """Get the path to the git executable."""
1599 git_path = shutil.which("git")
1600 if not git_path:
1601 raise FileNotFoundError("Git executable not found")
1602 if not Path(git_path).is_file():
1603 raise FileNotFoundError("Git executable path is invalid")
1604 return git_path
1605
1606
1607def init_git_repo(repo_path: Path, remote_url: str) -> Path:
1608 """Initialize a git repository for testing."""
1609 git_dir = repo_path / "test_repo"
1610 src_dir = git_dir / "src"
1611 src_dir.mkdir(parents=True)
1612
1613 git_path = get_git_path()
1614 if not git_path:
1615 raise FileNotFoundError("Git executable not found")
1616 if not Path(git_path).is_file():
1617 raise FileNotFoundError("Git executable path is invalid")
1618
1619 # Initialize git repo
1620 subprocess.run([git_path, "init"], cwd=git_dir, check=True, capture_output=True)
1621 subprocess.run(
1622 [git_path, "config", "user.email", "test@example.com"], cwd=git_dir, check=True
1623 )
1624 subprocess.run(
1625 [git_path, "config", "user.name", "Test User"], cwd=git_dir, check=True
1626 )
1627
1628 # Create a test file and commit
1629 test_file = src_dir / "test_file.py"
1630 test_file.write_text("# Test file\nprint('hello')\n")
1631 subprocess.run([git_path, "add", "."], cwd=git_dir, check=True)
1632 subprocess.run(
1633 [git_path, "commit", "-m", "Initial commit"], cwd=git_dir, check=True
1634 )
1635
1636 # Add a remote
1637 subprocess.run(
1638 [git_path, "remote", "add", "origin", remote_url],
1639 cwd=git_dir,
1640 check=True,
1641 )
1642
1643 return git_dir
1644
1645
1646@pytest.fixture(
1647 params=[
1648 ("test_repo_git", "git@github.com:test-user/test-repo.git"),
1649 ("test_repo_https", "https://github.com/test-user/test-repo.git"),
1650 ]
1651)
1652def git_repo(tmp_path: str, request: pytest.FixtureRequest) -> tuple[Path, str]:
1653 """Create git repos for testing."""
1654 repo_name, remote_url = request.param
1655 repo_path = Path(tmp_path) / repo_name
1656 repo_path = init_git_repo(repo_path, remote_url)
1657 return repo_path, remote_url
1658
1659
1660def get_current_commit_hash(git_dir: Path) -> str:
1661 """Get the current commit hash of the git repository."""
1662 git_path = get_git_path()
1663 result = subprocess.run(
1664 [git_path, "rev-parse", "HEAD"],
1665 cwd=git_dir,
1666 check=True,
1667 capture_output=True,
1668 text=True,
1669 )
1670
1671 return str(result.stdout.strip())
1672
1673
1674def test_locate_git_root(git_repo: tuple[Path, str]) -> None:
1675 repo_path = git_repo[0]
1676 src_dir = repo_path / "src"
1677 git_root = utils.locate_git_root(src_dir)
1678 assert git_root == repo_path
1679
1680
1681def test_get_remote_url(git_repo: tuple[Path, str]) -> None:
1682 repo_path, expected_url = git_repo
1683 remote_url = utils.get_remote_url(repo_path)
1684 assert remote_url == expected_url
1685
1686
1687def test_get_current_rev(git_repo: tuple[Path, str]) -> None:
1688 repo_path, _ = git_repo
1689 current_rev = get_current_commit_hash(repo_path)
1690 assert current_rev == utils.get_current_rev(repo_path)
1691
1692
1693@pytest.fixture(params=["detached", "branch"])
1694def git_worktree(
1695 tmp_path: Path, request: pytest.FixtureRequest
1696) -> tuple[Path, str, str]:
1697 """A LINKED WORKTREE of a scratch repository, in both shapes git makes.
1698
1699 A worktree is not a checkout with a `.git` directory: `.git` is a FILE holding
1700 `gitdir: <path>`, `config` lives in the main repository's git directory (named by
1701 the worktree's `commondir`), and only per-worktree state -- `HEAD` among it -- is in
1702 the worktree's own. `detached` puts a SHA in `HEAD`; `branch` puts `ref:
1703 refs/heads/...`, whose target is a shared ref in the common directory.
1704
1705 This repository is worked in worktrees as a matter of routine, so a helper that
1706 demands a `.git` directory fails for every developer while staying green in CI
1707 (useblocks/sphinx-codelinks#106).
1708 """
1709 remote_url = "https://github.com/test-user/test-repo.git"
1710 repo_path = init_git_repo(tmp_path / "main", remote_url)
1711 git_path = get_git_path()
1712 worktree_path = tmp_path / "worktree"
1713 add = [git_path, "worktree", "add"]
1714 add += (
1715 ["--detach", str(worktree_path), "HEAD"]
1716 if request.param == "detached"
1717 else [str(worktree_path), "-b", "a-worktree-branch"]
1718 )
1719 subprocess.run(add, cwd=repo_path, check=True, capture_output=True)
1720 assert (worktree_path / ".git").is_file(), "a linked worktree's .git is a file"
1721 return worktree_path, remote_url, get_current_commit_hash(repo_path)
1722
1723
1724def test_locate_git_root_worktree(git_worktree: tuple[Path, str, str]) -> None:
1725 worktree_path, _, _ = git_worktree
1726 assert utils.locate_git_root(worktree_path / "src") == worktree_path
1727
1728
1729def test_get_remote_url_worktree(git_worktree: tuple[Path, str, str]) -> None:
1730 worktree_path, remote_url, _ = git_worktree
1731 assert utils.get_remote_url(worktree_path) == remote_url
1732
1733
1734def test_get_current_rev_worktree(git_worktree: tuple[Path, str, str]) -> None:
1735 worktree_path, _, rev = git_worktree
1736 assert utils.get_current_rev(worktree_path) == rev
1737
1738
1739def test_get_current_rev_packed_refs(git_worktree: tuple[Path, str, str]) -> None:
1740 """`git gc` packs the loose refs away, and the branch ref is then in `packed-refs`.
1741
1742 Nothing unusual has to happen for this: `git gc` runs unattended on any long-lived
1743 checkout, and afterwards there is no `refs/heads/<branch>` FILE in either the
1744 worktree's git directory or the common one. It is the same failure shape as
1745 useblocks/sphinx-codelinks#106 reached by a different route -- and CI never sees it,
1746 because `actions/checkout` and Read the Docs both leave a detached HEAD.
1747 """
1748 worktree_path, _, rev = git_worktree
1749 git_path = get_git_path()
1750 subprocess.run(
1751 [git_path, "pack-refs", "--all"],
1752 cwd=worktree_path,
1753 check=True,
1754 capture_output=True,
1755 )
1756 git_dir = utils._git_dir(worktree_path)
1757 assert git_dir is not None
1758 common = utils._git_common_dir(git_dir)
1759 assert not list((common / "refs" / "heads").glob("*")), (
1760 "the loose refs should be gone"
1761 )
1762
1763 assert utils.get_current_rev(worktree_path) == rev
1764
1765
1766def test_get_current_rev_detached_head(tmp_path: Path) -> None:
1767 """In a detached HEAD (e.g. CI checkouts) .git/HEAD holds the commit SHA
1768 directly; get_current_rev returns it rather than warning and giving up."""
1769 git_root = tmp_path / "repo"
1770 (git_root / ".git").mkdir(parents=True)
1771 sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
1772 (git_root / ".git" / "HEAD").write_text(f"{sha}\n")
1773
1774 assert utils.get_current_rev(git_root) == sha
1775
1776
1777# ========== YAML-specific tests ==========
1778
1779
1780@pytest.mark.parametrize(
1781 ("code", "expected_structure"),
1782 [
1783 # Basic key-value pair
1784 (
1785 b"""
1786 # Comment before key
1787 database:
1788 host: localhost
1789 """,
1790 "database:",
1791 ),
1792 # Comment in nested structure
1793 (
1794 b"""
1795 services:
1796 web:
1797 # Comment before image
1798 image: nginx:latest
1799 """,
1800 "image: nginx:latest",
1801 ),
1802 # Comment before list item
1803 (
1804 b"""
1805 items:
1806 # Comment before list item
1807 - name: item1
1808 value: test
1809 """,
1810 "- name: item1",
1811 ),
1812 # Comment in document structure
1813 (
1814 b"""---
1815# Comment in document
1816version: "3.8"
1817services:
1818 app:
1819 build: .
1820 """,
1821 "version:",
1822 ),
1823 # Flow mapping structure
1824 (
1825 b"""
1826 # Comment before flow mapping
1827 config: {debug: true, port: 8080}
1828 """,
1829 "config:",
1830 ),
1831 ],
1832)
1833def test_find_yaml_next_structure(code, expected_structure, init_yaml_tree_sitter):
1834 """Test the find_yaml_next_structure function."""
1835 parser, query = init_yaml_tree_sitter
1836 comments = utils.extract_comments(code, parser, query)
1837 assert comments, "No comments found in the code"
1838
1839 next_structure = utils.find_yaml_next_structure(comments[0])
1840 assert next_structure, "No next structure found"
1841 structure_text = next_structure.text.decode("utf-8")
1842 assert expected_structure in structure_text
1843
1844
1845@pytest.mark.parametrize(
1846 ("code", "expected_structure"),
1847 [
1848 # Comment associated with key-value pair
1849 (
1850 b"""
1851 # Database configuration
1852 database:
1853 host: localhost
1854 port: 5432
1855 """,
1856 "database:",
1857 ),
1858 # Comment associated with nested structure
1859 (
1860 b"""
1861 services:
1862 web:
1863 # Web service image
1864 image: nginx:latest
1865 ports:
1866 - "80:80"
1867 """,
1868 "image: nginx:latest",
1869 ),
1870 # Comment associated with list item
1871 (
1872 b"""
1873 dependencies:
1874 # First dependency
1875 - name: redis
1876 version: "6.0"
1877 - name: postgres
1878 version: "13"
1879 """,
1880 "- name: redis",
1881 ),
1882 # Comment inside parent structure
1883 (
1884 b"""
1885 app:
1886 # Internal comment
1887 name: myapp
1888 version: "1.0"
1889 """,
1890 "name: myapp",
1891 ),
1892 ],
1893)
1894def test_find_yaml_associated_structure(
1895 code, expected_structure, init_yaml_tree_sitter
1896):
1897 """Test the find_yaml_associated_structure function."""
1898 parser, query = init_yaml_tree_sitter
1899 comments = utils.extract_comments(code, parser, query)
1900 assert comments, "No comments found in the code"
1901
1902 associated_structure = utils.find_yaml_associated_structure(comments[0])
1903 assert associated_structure, "No associated structure found"
1904 structure_text = associated_structure.text.decode("utf-8")
1905 assert expected_structure in structure_text
1906
1907
1908@pytest.mark.parametrize(
1909 ("code", "expected_results"),
1910 [
1911 # Multiple comments in sequence
1912 (
1913 b"""
1914 # First comment
1915 # Second comment
1916 database:
1917 host: localhost
1918 """,
1919 ["database:", "database:"], # Both comments should associate with database
1920 ),
1921 # Comments at different nesting levels
1922 (
1923 b"""
1924 # Top level comment
1925 services:
1926 web:
1927 # Nested comment
1928 image: nginx:latest
1929 """,
1930 ["services:", "image: nginx:latest"],
1931 ),
1932 ],
1933)
1934def test_multiple_yaml_comments(code, expected_results, init_yaml_tree_sitter):
1935 """Test handling of multiple YAML comments in the same file."""
1936 parser, query = init_yaml_tree_sitter
1937 comments = utils.extract_comments(code, parser, query)
1938 comments.sort(key=lambda x: x.start_point.row)
1939
1940 assert len(comments) == len(expected_results), (
1941 f"Expected {len(expected_results)} comments, found {len(comments)}"
1942 )
1943
1944 for i, comment in enumerate(comments):
1945 associated_structure = utils.find_yaml_associated_structure(comment)
1946 assert associated_structure, f"No associated structure found for comment {i}"
1947 structure_text = associated_structure.text.decode("utf-8")
1948 assert expected_results[i] in structure_text
1949
1950
1951@pytest.mark.parametrize(
1952 ("code", "has_structure"),
1953 [
1954 # Comment at end of file with no following structure
1955 (
1956 b"""
1957database:
1958 host: localhost
1959# End of file comment
1960 """,
1961 True, # This will actually find the parent database structure
1962 ),
1963 # Comment with only whitespace after
1964 (
1965 b"""
1966 # Lonely comment
1967
1968
1969 """,
1970 False,
1971 ),
1972 # Comment before valid structure
1973 (
1974 b"""
1975 # Valid comment
1976 key: value
1977 """,
1978 True,
1979 ),
1980 ],
1981)
1982def test_yaml_edge_cases(code, has_structure, init_yaml_tree_sitter):
1983 """Test edge cases in YAML comment processing."""
1984 parser, query = init_yaml_tree_sitter
1985 comments = utils.extract_comments(code, parser, query)
1986
1987 if comments:
1988 structure = utils.find_yaml_associated_structure(comments[0])
1989 if has_structure:
1990 assert structure, "Expected to find associated structure"
1991 else:
1992 assert structure is None, "Expected no associated structure"
1993 else:
1994 assert not has_structure, "No comments found but structure was expected"
1995
1996
1997@pytest.mark.parametrize(
1998 ("code", "expected_structures"),
1999 [
2000 # Simpler nested YAML structure
2001 (
2002 b"""# Global configuration
2003version: "3.8"
2004
2005# Services section
2006services:
2007 web:
2008 image: nginx:latest
2009 # Port configuration
2010 ports:
2011 - "80:80"
2012 """,
2013 [
2014 "version:", # Global configuration
2015 "services:", # Services section
2016 '- "80:80"', # Port configuration
2017 ],
2018 ),
2019 ],
2020)
2021def test_complex_yaml_structure(code, expected_structures, init_yaml_tree_sitter):
2022 """Test complex nested YAML structures with multiple comments."""
2023 parser, query = init_yaml_tree_sitter
2024 comments = utils.extract_comments(code, parser, query)
2025 comments.sort(key=lambda x: x.start_point.row)
2026
2027 assert len(comments) == len(expected_structures), (
2028 f"Expected {len(expected_structures)} comments, found {len(comments)}"
2029 )
2030
2031 for i, comment in enumerate(comments):
2032 associated_structure = utils.find_yaml_associated_structure(comment)
2033 assert associated_structure, f"No associated structure found for comment {i}"
2034 structure_text = associated_structure.text.decode("utf-8")
2035 assert expected_structures[i] in structure_text, (
2036 f"Expected '{expected_structures[i]}' in structure text: '{structure_text}'"
2037 )
2038
2039
2040@pytest.mark.parametrize(
2041 ("code", "expected_type"),
2042 [
2043 # Block mapping pair
2044 (
2045 b"""
2046 # Comment
2047 key: value
2048 """,
2049 "block_mapping_pair",
2050 ),
2051 # Block sequence item
2052 (
2053 b"""
2054 items:
2055 # Comment
2056 - item1
2057 """,
2058 "block_sequence_item",
2059 ),
2060 # Nested block mapping
2061 (
2062 b"""
2063 services:
2064 # Comment
2065 web:
2066 image: nginx
2067 """,
2068 "block_mapping_pair",
2069 ),
2070 ],
2071)
2072def test_yaml_structure_types(code, expected_type, init_yaml_tree_sitter):
2073 """Test that YAML structures return the correct node types."""
2074 parser, query = init_yaml_tree_sitter
2075 comments = utils.extract_comments(code, parser, query)
2076 assert comments, "No comments found"
2077
2078 structure = utils.find_yaml_associated_structure(comments[0])
2079 assert structure, "No associated structure found"
2080 assert structure.type == expected_type, (
2081 f"Expected type {expected_type}, got {structure.type}"
2082 )
2083
2084
2085def test_yaml_document_structure(init_yaml_tree_sitter):
2086 """Test YAML document structure handling."""
2087 code = b"""---
2088# Document comment
2089apiVersion: v1
2090kind: ConfigMap
2091metadata:
2092 name: my-config
2093data:
2094 # Data comment
2095 config.yml: |
2096 setting: value
2097 """
2098
2099 parser, query = init_yaml_tree_sitter
2100 comments = utils.extract_comments(code, parser, query)
2101 comments.sort(key=lambda x: x.start_point.row)
2102
2103 # Should find both comments
2104 assert len(comments) >= 2, f"Expected at least 2 comments, found {len(comments)}"
2105
2106 # First comment should associate with apiVersion
2107 first_structure = utils.find_yaml_associated_structure(comments[0])
2108 assert first_structure, "No structure found for first comment"
2109 first_text = first_structure.text.decode("utf-8")
2110 assert "apiVersion:" in first_text
2111
2112 # Second comment should associate with config.yml
2113 second_structure = utils.find_yaml_associated_structure(comments[1])
2114 assert second_structure, "No structure found for second comment"
2115 second_text = second_structure.text.decode("utf-8")
2116 assert "config.yml:" in second_text
2117
2118
2119def test_yaml_inline_comments_current_behavior(init_yaml_tree_sitter):
2120 """Test improved behavior of inline comments in YAML after the fix."""
2121 code = b"""key1: value1 # inline comment about key1
2122key2: value2
2123key3: value3 # inline comment about key3
2124"""
2125
2126 parser, query = init_yaml_tree_sitter
2127 comments = utils.extract_comments(code, parser, query)
2128 comments.sort(key=lambda x: x.start_point.row)
2129
2130 assert len(comments) == 2, f"Expected 2 comments, found {len(comments)}"
2131
2132 # Fixed behavior: inline comment about key1 now correctly associates with key1
2133 first_structure = utils.find_yaml_associated_structure(comments[0])
2134 assert first_structure, "No structure found for first comment"
2135 first_text = first_structure.text.decode("utf-8")
2136 assert "key1:" in first_text, f"Expected 'key1:' in '{first_text}'"
2137
2138 # Fixed behavior: inline comment about key3 now correctly associates with key3
2139 second_structure = utils.find_yaml_associated_structure(comments[1])
2140 assert second_structure, "No structure found for second comment"
2141 second_text = second_structure.text.decode("utf-8")
2142 assert "key3:" in second_text, f"Expected 'key3:' in '{second_text}'"
2143
2144
2145@pytest.mark.parametrize(
2146 ("code", "expected_associations"),
2147 [
2148 # Basic inline comment case
2149 (
2150 b"""key1: value1 # comment about key1
2151key2: value2
2152 """,
2153 ["key1:"], # Now correctly associates with key1
2154 ),
2155 # Multiple inline comments
2156 (
2157 b"""database:
2158 host: localhost # production server
2159 port: 5432 # default postgres port
2160 user: admin
2161 """,
2162 [
2163 "host: localhost",
2164 "port: 5432",
2165 ], # Now correctly associates with the right structures
2166 ),
2167 ],
2168)
2169def test_yaml_inline_comments_fixed_behavior(
2170 code, expected_associations, init_yaml_tree_sitter
2171):
2172 """Test that inline comments now correctly associate with the structure they comment on."""
2173 parser, query = init_yaml_tree_sitter
2174 comments = utils.extract_comments(code, parser, query)
2175 comments.sort(key=lambda x: x.start_point.row)
2176
2177 assert len(comments) == len(expected_associations), (
2178 f"Expected {len(expected_associations)} comments, found {len(comments)}"
2179 )
2180
2181 for i, comment in enumerate(comments):
2182 structure = utils.find_yaml_associated_structure(comment)
2183 assert structure, f"No structure found for comment {i}"
2184 structure_text = structure.text.decode("utf-8")
2185 assert expected_associations[i] in structure_text, (
2186 f"Expected '{expected_associations[i]}' in structure text: '{structure_text}'"
2187 )
2188
2189
2190@pytest.mark.parametrize(
2191 ("code", "expected_associations"),
2192 [
2193 # Inline comments with list items
2194 (
2195 b"""items:
2196 - name: item1 # first item
2197 - name: item2 # second item
2198 """,
2199 [
2200 "name: item1",
2201 "name: item2",
2202 ], # The inline comment finds the key-value pair within the list item
2203 ),
2204 # Mixed inline and block comments
2205 (
2206 b"""# Block comment for database
2207database:
2208 host: localhost # inline comment for host
2209 port: 5432
2210 # Block comment for user
2211 user: admin
2212 """,
2213 ["database:", "host: localhost", "user: admin"],
2214 ),
2215 # Inline comments in nested structures
2216 (
2217 b"""services:
2218 web:
2219 image: nginx # web server image
2220 ports:
2221 - "80:80" # http port
2222 """,
2223 ["image: nginx", '- "80:80"'],
2224 ),
2225 ],
2226)
2227def test_yaml_inline_comments_comprehensive(
2228 code, expected_associations, init_yaml_tree_sitter
2229):
2230 """Comprehensive test for inline comment behavior in various YAML structures."""
2231 parser, query = init_yaml_tree_sitter
2232 comments = utils.extract_comments(code, parser, query)
2233 comments.sort(key=lambda x: x.start_point.row)
2234
2235 assert len(comments) == len(expected_associations), (
2236 f"Expected {len(expected_associations)} comments, found {len(comments)}"
2237 )
2238
2239 for i, comment in enumerate(comments):
2240 structure = utils.find_yaml_associated_structure(comment)
2241 assert structure, (
2242 f"No structure found for comment {i}: '{comment.text.decode('utf-8')}'"
2243 )
2244 structure_text = structure.text.decode("utf-8")
2245 assert expected_associations[i] in structure_text, (
2246 f"Comment {i} '{comment.text.decode('utf-8')}' -> Expected '{expected_associations[i]}' in '{structure_text}'"
2247 )
2248
2249
2250@pytest.mark.parametrize(
2251 ("text", "expected"),
2252 [
2253 pytest.param("a@file.com", False, id="address"),
2254 pytest.param("x@module", False, id="no-left-boundary"),
2255 pytest.param("* @file", True, id="after-star"),
2256 pytest.param("\\file widget.h", True, id="backslash-at-start"),
2257 pytest.param("@filename", False, id="longer-word"),
2258 pytest.param("@file_x", False, id="underscore"),
2259 pytest.param("/// @fileoverview Widgets.", True, id="fileoverview"),
2260 pytest.param("@fileÍ… x", True, id="non-ascii-mark-ends-the-word"),
2261 ],
2262)
2263def test_documents_file(text: str, expected: bool) -> None:
2264 """A file tag counts at the start of the text or after whitespace, ``*``, ``/`` or
2265 ``!``, and only as a whole ASCII word: an address or a longer word never counts,
2266 and a non-ASCII mark ends the word rather than extending it."""
2267 assert utils.documents_file(text) is expected