sphinx-codelinks 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/PKG-INFO +6 -4
  2. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/pyproject.toml +6 -68
  3. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/analyse/analyse.py +25 -19
  4. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/analyse/oneline_parser.py +0 -9
  5. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/analyse/projects.py +4 -10
  6. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/analyse/utils.py +119 -22
  7. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/cmd.py +12 -4
  8. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/config.py +20 -0
  9. sphinx_codelinks-1.3.0/src/sphinx_codelinks/logger.py +261 -0
  10. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/source_discover/config.py +11 -0
  11. sphinx_codelinks-1.3.0/src/sphinx_codelinks/source_discover/source_discover.py +112 -0
  12. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +21 -16
  13. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/sphinx_extension/source_tracing.py +42 -5
  14. sphinx_codelinks-1.2.0/src/sphinx_codelinks/logger.py +0 -94
  15. sphinx_codelinks-1.2.0/src/sphinx_codelinks/source_discover/source_discover.py +0 -67
  16. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/LICENSE +0 -0
  17. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/README.md +0 -0
  18. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/__init__.py +0 -0
  19. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/analyse/models.py +0 -0
  20. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/needextend_write.py +0 -0
  21. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/sphinx_extension/debug.py +0 -0
  22. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/sphinx_extension/html_wrapper.py +0 -0
  23. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.3.0}/src/sphinx_codelinks/sphinx_extension/ub_sct.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sphinx-codelinks
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Fast Source Code Traceability for Sphinx-Needs
5
5
  Author-email: team useblocks <info@useblocks.com>
6
6
  Maintainer-email: Marco Heinemann <marco.heinemann@useblocks.com>, Jui-Wen Chen <jui-wen.chen@useblocks.com>
@@ -8,12 +8,12 @@ Requires-Python: >= 3.12
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: comment-parser>=1.2.4
11
- Requires-Dist: gitignore-parser>=0.1.11
11
+ Requires-Dist: ignore-python>=0.3.3
12
12
  Requires-Dist: typer>=0.16.0
13
13
  Requires-Dist: click < 8.2
14
14
  Requires-Dist: jsonschema
15
- Requires-Dist: sphinx>=7.4,<9
16
- Requires-Dist: sphinx-needs>=4.2.0
15
+ Requires-Dist: sphinx>=7.4,<10
16
+ Requires-Dist: sphinx-needs>=5,<9
17
17
  Requires-Dist: jinja2
18
18
  Requires-Dist: pygments
19
19
  Requires-Dist: docutils
@@ -24,6 +24,8 @@ Requires-Dist: tree-sitter~=0.25.1
24
24
  Requires-Dist: tree-sitter-c-sharp>=0.23.1
25
25
  Requires-Dist: tree-sitter-yaml>=0.7.1
26
26
  Requires-Dist: tree-sitter-rust>=0.23.0
27
+ Requires-Dist: tree-sitter-go>=0.23.0
28
+ Requires-Dist: tree-sitter-json>=0.24.8
27
29
 
28
30
  # Sphinx CodeLinks
29
31
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sphinx-codelinks"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Fast Source Code Traceability for Sphinx-Needs"
5
5
  authors = [{ name = "team useblocks", email = "info@useblocks.com" }]
6
6
  maintainers = [
@@ -12,12 +12,12 @@ readme = "README.md"
12
12
  requires-python = ">= 3.12"
13
13
  dependencies = [
14
14
  "comment-parser>=1.2.4",
15
- "gitignore-parser>=0.1.11",
15
+ "ignore-python>=0.3.3",
16
16
  "typer>=0.16.0",
17
17
  "click < 8.2", # click 8.2.* produces empty errors if no args are given
18
18
  "jsonschema",
19
- "sphinx>=7.4,<9",
20
- "sphinx-needs>=4.2.0", # unconstrained versions, to be pinned by user or Sphinx
19
+ "sphinx>=7.4,<10",
20
+ "sphinx-needs>=5,<9",
21
21
  "jinja2",
22
22
  "pygments",
23
23
  "docutils", # constrained by user or Sphinx
@@ -30,6 +30,8 @@ dependencies = [
30
30
  "tree-sitter-c-sharp>=0.23.1",
31
31
  "tree-sitter-yaml>=0.7.1",
32
32
  "tree-sitter-rust>=0.23.0",
33
+ "tree-sitter-go>=0.23.0",
34
+ "tree-sitter-json>=0.24.8",
33
35
  ]
34
36
 
35
37
  [build-system]
@@ -66,64 +68,9 @@ mypy = [
66
68
  ruff = ["ruff>=0.8.0"]
67
69
  build = ["simple-build>=0.0.2", "shiv>=1.0.8"]
68
70
 
69
- [tool.rye]
70
- managed = true
71
- dev-dependencies = [
72
- "types-docutils",
73
- "types-Pygments",
74
- "syrupy>=4.9.1",
75
- "furo>=2024.5.6",
76
- "moto ~= 5.0",
77
- "mypy>=1.12.1",
78
- "myst-parser>=4.0.0",
79
- "pydantic ~= 2.9",
80
- "pip-licenses>=5.0.0",
81
- "psutil>=7.0.0",
82
- "pytest-cov>=5.0.0",
83
- "pytest>=8.2.2",
84
- "simple-build>=0.0.2",
85
- "sphinx-design>=0.6.1",
86
- "types-psutil>=7.0.0.20250218",
87
- "uv>=0.5.5",
88
- "pytest-docker>=3.1.2",
89
- "shiv>=1.0.8",
90
- "insta-science>=0.2.1",
91
- "types-jsonschema>=4.23.0.20241208",
92
- "toml>=0.10.2",
93
- "sphinx-code-tabs>=0.5.5",
94
- "sphinxcontrib-typer>=0.5.1",
95
- "sphinxcontrib-video>=0.4.1",
96
- ]
97
-
98
71
  [project.scripts]
99
72
  codelinks = "sphinx_codelinks.cmd:app"
100
73
 
101
- [tool.rye.scripts]
102
- # linting and formatting
103
- "mypy:all" = "mypy ."
104
- "rye:lint" = "rye lint"
105
- "rye:format" = "rye format"
106
- "check" = { chain = ["rye:format", "rye:lint", "mypy:all"] }
107
- # docs html
108
- "docs:rm" = "rm -rf docs/_build/html"
109
- "docs" = "sphinx-build -nW --keep-going -b html -T -c docs docs/source docs/_build/html"
110
- "docs:clean" = { chain = ["docs:rm", "docs"] }
111
- # needextend demo
112
- "analyse" = "codelinks analyse tests/data/configs/minimum_config.toml"
113
- "analyse:rm" = "rm -rf output/marked_content.json"
114
- "write" = "codelinks write rst output/marked_content.json --outpath tests/data/needextend_demo/needextend.rst"
115
- "write:rm" = "rm -rf tests/data/needextend_demo/needextend.rst"
116
- "demo:rm" = "rm -rf tests/data/needextend_demo/_build"
117
- "demo:build" = "sphinx-build -nW --keep-going -b html -T -c tests/data/needextend_demo tests/data/needextend_demo tests/data/needextend_demo/_build/html"
118
- "demo:clean" = { chain = [
119
- "demo:rm",
120
- "analyse:rm",
121
- "write:rm",
122
- "analyse",
123
- "write",
124
- "demo:build",
125
- ] }
126
-
127
74
  [tool.ruff.lint]
128
75
  extend-select = [
129
76
  # "ANN",
@@ -162,15 +109,6 @@ force-sort-within-sections = true
162
109
  "PLR2004", # magic-value-comparison - valueable for tests
163
110
  "S101", # assert - needed for tests
164
111
  ]
165
- "**/build_hooks_*/**" = [
166
- "S607", # start-process-with-partial-path - pyarmor call in rye context
167
- "S603", # subprocess-without-shell-equals-true - pyarmor call
168
- ]
169
- "scripts/*.py" = [
170
- "T201", # print - used for output
171
- "S607", # start-process-with-partial-path - pyarmor call in rye context
172
- "S603", # subprocess-without-shell-equals-true - build scripts
173
- ]
174
112
  "src/sphinx_codelinks/sphinx_extension/debug.py" = [
175
113
  "T201", # print - used for output
176
114
  "UP047", # on-pep695-generic-function - it's generic
@@ -1,7 +1,6 @@
1
1
  from collections.abc import Generator
2
2
  from dataclasses import dataclass
3
3
  import json
4
- import logging
5
4
  from pathlib import Path
6
5
  from typing import Any, TypedDict
7
6
 
@@ -26,14 +25,14 @@ from sphinx_codelinks.config import (
26
25
  OneLineCommentStyle,
27
26
  SourceAnalyseConfig,
28
27
  )
28
+ from sphinx_codelinks.logger import get_logger
29
29
 
30
- # initialize logger
31
- logger = logging.getLogger(__name__)
32
- logger.setLevel(logging.INFO)
33
- # log to the console
34
- console = logging.StreamHandler()
35
- console.setLevel(logging.INFO)
36
- logger.addHandler(console)
30
+ logger = get_logger(__name__)
31
+
32
+
33
+ def _count(n: int, noun: str) -> str:
34
+ """Format ``n noun`` with a naive (append-s) plural for progress summaries."""
35
+ return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
37
36
 
38
37
 
39
38
  class AnalyseWarningType(TypedDict):
@@ -57,7 +56,10 @@ class SourceAnalyse:
57
56
  def __init__(
58
57
  self,
59
58
  analyse_config: SourceAnalyseConfig,
59
+ *,
60
+ name: str = "",
60
61
  ) -> None:
62
+ self.name = name
61
63
  self.analyse_config = analyse_config
62
64
  self.src_files: list[SourceFile] = []
63
65
  self.src_comments: list[SourceComment] = []
@@ -110,9 +112,6 @@ class SourceAnalyse:
110
112
  self.src_files.append(src_file)
111
113
  self.src_comments.extend(src_comments)
112
114
 
113
- logger.info(f"Source files loaded: {len(self.src_files)}")
114
- logger.info(f"Source comments extracted: {len(self.src_comments)}")
115
-
116
115
  def extract_marker(
117
116
  self,
118
117
  text: str,
@@ -353,13 +352,6 @@ class SourceAnalyse:
353
352
  if marked_rst:
354
353
  self.marked_rst.append(marked_rst)
355
354
 
356
- if self.analyse_config.get_need_id_refs:
357
- logger.info(f"Need-id-refs extracted: {len(self.need_id_refs)}")
358
- if self.analyse_config.get_oneline_needs:
359
- logger.info(f"Oneline needs extracted: {len(self.oneline_needs)}")
360
- if self.analyse_config.get_rst:
361
- logger.info(f"Marked rst extracted: {len(self.marked_rst)}")
362
-
363
355
  def merge_marked_content(self) -> None:
364
356
  self.all_marked_content.extend(self.need_id_refs)
365
357
  self.oneline_needs.sort(key=lambda x: x.source_map["start"]["row"])
@@ -378,9 +370,23 @@ class SourceAnalyse:
378
370
  ]
379
371
  with output_path.open("w") as f:
380
372
  json.dump(to_dump, f)
381
- logger.info(f"Marked content dumped to {output_path}")
382
373
 
383
374
  def run(self) -> None:
384
375
  self.create_src_objects()
385
376
  self.extract_marked_content()
386
377
  self.merge_marked_content()
378
+ self._log_summary()
379
+
380
+ def _log_summary(self) -> None:
381
+ """Emit a per-project marker (default-visible) plus a -v breakdown."""
382
+ label = f"codelinks [{self.name}]" if self.name else "codelinks"
383
+ logger.info(
384
+ f"{label}: {_count(len(self.src_files), 'file')}, "
385
+ f"{_count(len(self.all_marked_content), 'marker')}"
386
+ )
387
+ logger.debug(
388
+ f"{label}: {_count(len(self.src_comments), 'comment')}, "
389
+ f"{_count(len(self.oneline_needs), 'oneline need')}, "
390
+ f"{_count(len(self.need_id_refs), 'id-ref')}, "
391
+ f"{_count(len(self.marked_rst), 'marked-rst block')}"
392
+ )
@@ -1,17 +1,8 @@
1
1
  from dataclasses import dataclass
2
2
  from enum import Enum
3
- import logging
4
3
 
5
4
  from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
6
5
 
7
- # initialize logger
8
- logger = logging.getLogger(__name__)
9
- logger.setLevel(logging.INFO)
10
- # log to the console
11
- console = logging.StreamHandler()
12
- console.setLevel(logging.INFO)
13
- logger.addHandler(console)
14
-
15
6
 
16
7
  class WarningSubTypeEnum(str, Enum):
17
8
  """Enum for warning sub types."""
@@ -1,5 +1,4 @@
1
1
  import json
2
- import logging
3
2
  from pathlib import Path
4
3
  from typing import cast
5
4
 
@@ -9,14 +8,9 @@ from sphinx_codelinks.analyse.analyse import (
9
8
  SourceAnalyse,
10
9
  )
11
10
  from sphinx_codelinks.config import CodeLinksConfig, CodeLinksProjectConfigType
11
+ from sphinx_codelinks.logger import get_logger
12
12
 
13
- # initialize logger
14
- logger = logging.getLogger(__name__)
15
- logger.setLevel(logging.INFO)
16
- # log to the console
17
- console = logging.StreamHandler()
18
- console.setLevel(logging.INFO)
19
- logger.addHandler(console)
13
+ logger = get_logger(__name__)
20
14
 
21
15
 
22
16
  class AnalyseProjects:
@@ -32,7 +26,7 @@ class AnalyseProjects:
32
26
 
33
27
  def run(self) -> None:
34
28
  for project, config in self.projects_configs.items():
35
- src_analyse = SourceAnalyse(config["analyse_config"])
29
+ src_analyse = SourceAnalyse(config["analyse_config"], name=project)
36
30
  src_analyse.run()
37
31
  self.projects_analyse[project] = src_analyse
38
32
 
@@ -46,7 +40,7 @@ class AnalyseProjects:
46
40
  }
47
41
  with output_path.open("w") as f:
48
42
  json.dump(to_dump, f)
49
- logger.info(f"Marked content dumped to {output_path}")
43
+ logger.debug(f"codelinks: marked content dumped to {output_path}")
50
44
 
51
45
  @classmethod
52
46
  def load_warnings(cls, warnings_dir: Path) -> list[AnalyseWarning] | None:
@@ -1,6 +1,5 @@
1
1
  from collections.abc import ByteString, Callable
2
2
  import configparser
3
- import logging
4
3
  from pathlib import Path
5
4
  from typing import TypedDict
6
5
  from urllib.request import pathname2url
@@ -10,16 +9,25 @@ from tree_sitter import Language, Parser, Point, Query, QueryCursor
10
9
  from tree_sitter import Node as TreeSitterNode
11
10
 
12
11
  from sphinx_codelinks.config import UNIX_NEWLINE, CommentCategory
12
+ from sphinx_codelinks.logger import get_logger
13
13
  from sphinx_codelinks.source_discover.config import CommentType
14
14
 
15
- # Language-specific node types for scope detection
15
+ # Language-specific node types for scope detection.
16
+ #
17
+ # YAML and JSONC are intentionally absent. They are data formats, not code, so a
18
+ # comment associates with the surrounding data structure (key/value pair, list
19
+ # item, or scalar) rather than with an enclosing or following declaration. That
20
+ # needs a different algorithm (inline same-row association first, scalar targets,
21
+ # grammar-specific traversal), implemented in find_yaml_associated_structure and
22
+ # find_jsonc_associated_structure and dispatched from find_associated_scope.
23
+ # Those bespoke finders never read this table (only find_next_scope and
24
+ # find_enclosing_scope do), so an entry here would be dead.
16
25
  SCOPE_NODE_TYPES = {
17
26
  # @Python Scope Node Types, IMPL_PY_2, impl, [FE_PY]
18
27
  CommentType.python: {"function_definition", "class_definition"},
19
28
  # @C and C++ Scope Node Types, IMPL_C_2, impl, [FE_C_SUPPORT, FE_CPP]
20
29
  CommentType.cpp: {"function_definition", "class_definition"},
21
30
  CommentType.cs: {"method_declaration", "class_declaration", "property_declaration"},
22
- CommentType.yaml: {"block_mapping_pair", "block_sequence_item", "document"},
23
31
  # @Rust Scope Node Types, IMPL_RUST_2, impl, [FE_RUST];
24
32
  CommentType.rust: {
25
33
  "function_item",
@@ -29,15 +37,16 @@ SCOPE_NODE_TYPES = {
29
37
  "trait_item",
30
38
  "mod_item",
31
39
  },
40
+ # @Go Scope Node Types, IMPL_GO_4, impl, [FE_GO]
41
+ CommentType.go: {
42
+ "function_declaration",
43
+ "method_declaration",
44
+ "type_declaration",
45
+ "type_spec",
46
+ },
32
47
  }
33
48
 
34
- # initialize logger
35
- logger = logging.getLogger(__name__)
36
- logger.setLevel(logging.INFO)
37
- # log to the console
38
- console = logging.StreamHandler()
39
- console.setLevel(logging.INFO)
40
- logger.addHandler(console)
49
+ logger = get_logger(__name__)
41
50
 
42
51
  GIT_HOST_URL_TEMPLATE = {
43
52
  "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
@@ -60,6 +69,23 @@ RUST_QUERY = """
60
69
  (line_comment) @comment
61
70
  (block_comment) @comment
62
71
  """
72
+ # @Go comment query for tree-sitter, IMPL_GO_3, impl, [FE_GO]
73
+ GO_QUERY = """
74
+ (comment) @comment
75
+ """
76
+ JSONC_QUERY = """(comment) @comment"""
77
+
78
+ # JSON value node types that can be associated with a comment.
79
+ JSON_STRUCTURE_TYPES = {
80
+ "pair",
81
+ "object",
82
+ "array",
83
+ "string",
84
+ "number",
85
+ "true",
86
+ "false",
87
+ "null",
88
+ }
63
89
 
64
90
 
65
91
  def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
@@ -77,7 +103,7 @@ def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
77
103
  return False
78
104
 
79
105
 
80
- # @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST]
106
+ # @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC]
81
107
  def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
82
108
  if comment_type == CommentType.cpp:
83
109
  import tree_sitter_cpp # noqa: PLC0415
@@ -104,6 +130,16 @@ def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
104
130
 
105
131
  parsed_language = Language(tree_sitter_rust.language())
106
132
  query = Query(parsed_language, RUST_QUERY)
133
+ elif comment_type == CommentType.go:
134
+ import tree_sitter_go # noqa: PLC0415
135
+
136
+ parsed_language = Language(tree_sitter_go.language())
137
+ query = Query(parsed_language, GO_QUERY)
138
+ elif comment_type == CommentType.jsonc:
139
+ import tree_sitter_json # noqa: PLC0415
140
+
141
+ parsed_language = Language(tree_sitter_json.language())
142
+ query = Query(parsed_language, JSONC_QUERY)
107
143
  else:
108
144
  raise ValueError(f"Unsupported comment style: {comment_type}")
109
145
  parser = Parser(parsed_language)
@@ -203,8 +239,11 @@ def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
203
239
  return None
204
240
 
205
241
 
206
- def find_yaml_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
207
- """Find a previous named sibling that is on the same row as the comment."""
242
+ def find_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
243
+ """Find a previous named sibling that is on the same row as the comment.
244
+
245
+ Grammar-agnostic: used to detect inline comments in both YAML and JSONC.
246
+ """
208
247
  comment_row = node.start_point.row
209
248
  current = node.prev_named_sibling
210
249
 
@@ -225,7 +264,7 @@ def find_yaml_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode |
225
264
  def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
226
265
  """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
227
266
  # First, check if this is an inline comment by looking for a previous sibling on the same row
228
- prev_sibling_same_row = find_yaml_prev_sibling_on_same_row(node)
267
+ prev_sibling_same_row = find_prev_sibling_on_same_row(node)
229
268
  if prev_sibling_same_row:
230
269
  return prev_sibling_same_row
231
270
 
@@ -244,6 +283,36 @@ def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | Non
244
283
  return None
245
284
 
246
285
 
286
+ # @JSONC comment-to-structure association, IMPL_JSONC_2, impl, [FE_JSONC]
287
+ def find_jsonc_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
288
+ """Find the JSON structure (key/value pair, value, list item) for a comment.
289
+
290
+ JSON is data rather than code, so association follows the same intent as YAML:
291
+ an inline comment belongs to the value on its row, a leading comment belongs to
292
+ the following structure, otherwise it belongs to the enclosing structure.
293
+ """
294
+ # Inline comment: a value/pair on the same row, before the comment
295
+ prev_sibling_same_row = find_prev_sibling_on_same_row(node)
296
+ if prev_sibling_same_row:
297
+ return prev_sibling_same_row
298
+
299
+ # Leading comment: the next structure following the comment
300
+ current = node.next_named_sibling
301
+ while current:
302
+ if current.type in JSON_STRUCTURE_TYPES:
303
+ return current
304
+ current = current.next_named_sibling
305
+
306
+ # Otherwise: the enclosing structure
307
+ parent = node.parent
308
+ while parent:
309
+ if parent.type in {"pair", "object", "array"}:
310
+ return parent
311
+ parent = parent.parent
312
+
313
+ return None
314
+
315
+
247
316
  def find_associated_scope(
248
317
  node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
249
318
  ) -> TreeSitterNode | None:
@@ -252,6 +321,10 @@ def find_associated_scope(
252
321
  # YAML uses different structure association logic
253
322
  return find_yaml_associated_structure(node)
254
323
 
324
+ if comment_type == CommentType.jsonc:
325
+ # JSONC uses data-aware structure association logic
326
+ return find_jsonc_associated_structure(node)
327
+
255
328
  if node.type == CommentCategory.docstring:
256
329
  # Only for python's docstring
257
330
  return find_enclosing_scope(node, comment_type)
@@ -270,7 +343,11 @@ def locate_git_root(src_dir: Path) -> Path | None:
270
343
  for parent in parents:
271
344
  if (parent / ".git").exists() and (parent / ".git").is_dir():
272
345
  return parent
273
- logger.warning(f"git root is not found in the parent of {src_dir}")
346
+ logger.warning(
347
+ f"git root is not found in the parent of {src_dir}",
348
+ subtype="git_root",
349
+ location=str(src_dir),
350
+ )
274
351
  return None
275
352
 
276
353
 
@@ -278,7 +355,11 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
278
355
  """Get remote url from .git/config."""
279
356
  config_path = git_root / ".git" / "config"
280
357
  if not config_path.exists():
281
- logging.warning(f"{config_path} does not exist")
358
+ logger.warning(
359
+ f"{config_path} does not exist",
360
+ subtype="git_config",
361
+ location=str(config_path),
362
+ )
282
363
  return None
283
364
 
284
365
  config = configparser.ConfigParser(allow_no_value=True, strict=False)
@@ -287,7 +368,11 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
287
368
  if section in config and "url" in config[section]:
288
369
  url: str = config[section]["url"]
289
370
  return url
290
- logger.warning(f"remote-url is not found in {config_path}")
371
+ logger.warning(
372
+ f"remote-url is not found in {config_path}",
373
+ subtype="git_remote",
374
+ location=str(config_path),
375
+ )
291
376
  return None
292
377
 
293
378
 
@@ -295,16 +380,25 @@ def get_current_rev(git_root: Path) -> str | None:
295
380
  """Get current commit rev from .git/HEAD."""
296
381
  head_path = git_root / ".git" / "HEAD"
297
382
  if not head_path.exists():
298
- logging.warning(f"{head_path} does not exist")
383
+ logger.warning(
384
+ f"{head_path} does not exist",
385
+ subtype="git_head",
386
+ location=str(head_path),
387
+ )
299
388
  return None
300
389
  head_content = head_path.read_text().strip()
301
390
  if not head_content.startswith("ref: "):
302
- logging.warning(f"Expect starting with 'ref: ' in {head_path}")
303
- return None
391
+ # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
392
+ # directly, which is exactly the rev we want.
393
+ return head_content
304
394
 
305
395
  ref_path = git_root / ".git" / head_content.split(":", 1)[1].strip()
306
396
  if not ref_path.exists():
307
- logging.warning(f"{ref_path} does not exist")
397
+ logger.warning(
398
+ f"{ref_path} does not exist",
399
+ subtype="git_ref",
400
+ location=str(ref_path),
401
+ )
308
402
  return None
309
403
  return ref_path.read_text().strip()
310
404
 
@@ -315,7 +409,10 @@ def form_https_url(
315
409
  parsed_url = parse(git_url)
316
410
  template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
317
411
  if not template:
318
- logging.warning(f"Unsupported Git host: {parsed_url.platform}")
412
+ logger.warning(
413
+ f"Unsupported Git host: {parsed_url.platform}",
414
+ subtype="git_host",
415
+ )
319
416
  return git_url
320
417
  https_url = template.format(
321
418
  owner=parsed_url.owner,
@@ -14,7 +14,7 @@ from sphinx_codelinks.config import (
14
14
  CodeLinksProjectConfigType,
15
15
  generate_project_configs,
16
16
  )
17
- from sphinx_codelinks.logger import logger
17
+ from sphinx_codelinks.logger import configure_cli, logger
18
18
  from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content
19
19
  from sphinx_codelinks.source_discover.config import (
20
20
  CommentType,
@@ -88,9 +88,12 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches
88
88
  exists=True,
89
89
  ),
90
90
  ] = None,
91
+ verbose: OptVerbose = False,
92
+ quiet: OptQuiet = False,
91
93
  ) -> None:
92
94
  """Analyse marked content in source code."""
93
95
  # @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE]
96
+ configure_cli(verbose, quiet)
94
97
 
95
98
  data: CodeLinksConfigType = load_config_from_toml(config)
96
99
 
@@ -170,7 +173,7 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches
170
173
 
171
174
 
172
175
  @app.command(no_args_is_help=True)
173
- def discover(
176
+ def discover( # noqa: PLR0913 # CLI command requires multiple parameters
174
177
  src_dir: Annotated[
175
178
  Path,
176
179
  typer.Argument(
@@ -203,9 +206,13 @@ def discover(
203
206
  gitignore: Annotated[
204
207
  bool,
205
208
  typer.Option(
206
- help="Respect .gitignore in the given directory. Nested .gitignore Not supported"
209
+ help="Respect .gitignore files in the given directory and its parents"
207
210
  ),
208
211
  ] = True,
212
+ follow_links: Annotated[
213
+ bool,
214
+ typer.Option(help="Follow symbolic links during file discovery"),
215
+ ] = False,
209
216
  comment_type: Annotated[
210
217
  CommentType,
211
218
  typer.Option(
@@ -222,6 +229,7 @@ def discover(
222
229
  "exclude": exclude,
223
230
  "include": include,
224
231
  "gitignore": gitignore,
232
+ "follow_links": follow_links,
225
233
  "comment_type": comment_type,
226
234
  }
227
235
 
@@ -286,7 +294,7 @@ def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires
286
294
  quiet: OptQuiet = False,
287
295
  ) -> None:
288
296
  """Generate needextend.rst from the extracted obj in JSON."""
289
- logger.configure(verbose, quiet)
297
+ configure_cli(verbose, quiet)
290
298
  try:
291
299
  with jsonpath.open("r") as f:
292
300
  marked_content = json.load(f)
@@ -24,6 +24,8 @@ COMMENT_MARKERS = {
24
24
  # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
25
25
  CommentType.python: ["#"],
26
26
  CommentType.cs: ["//", "/*", "///"],
27
+ # @Support Go style comments, IMPL_GO_2, impl, [FE_GO]
28
+ CommentType.go: ["//", "/*"],
27
29
  }
28
30
  ESCAPE = "\\"
29
31
 
@@ -276,11 +278,29 @@ class OneLineCommentStyle:
276
278
  needs_field_names.add(_field["name"])
277
279
  return errors
278
280
 
281
+ def check_fields_default_order(self) -> list[str]:
282
+ errors = []
283
+ seen_default = False
284
+ first_default_field = ""
285
+ for _field in self.needs_fields:
286
+ has_default = _field.get("default") is not None
287
+ if has_default and not seen_default:
288
+ seen_default = True
289
+ first_default_field = _field["name"]
290
+ elif not has_default and seen_default:
291
+ errors.append(
292
+ f"Field '{_field['name']}' without a default follows "
293
+ f"field '{first_default_field}' which has a default. "
294
+ f"Fields without defaults must be defined before fields with defaults."
295
+ )
296
+ return errors
297
+
279
298
  def check_fields_configuration(self) -> list[str]:
280
299
  return (
281
300
  self.check_schema()
282
301
  + self.check_required_fields()
283
302
  + self.check_fields_mutually_exclusive()
303
+ + self.check_fields_default_order()
284
304
  )
285
305
 
286
306
  def get_cnt_required_fields(self) -> int: