sphinx-codelinks 1.0.0__tar.gz → 1.2.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 (21) hide show
  1. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/PKG-INFO +4 -1
  2. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/pyproject.toml +34 -2
  3. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/analyse/analyse.py +17 -10
  4. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/analyse/oneline_parser.py +3 -19
  5. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/analyse/utils.py +146 -9
  6. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/cmd.py +19 -1
  7. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/config.py +44 -11
  8. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/needextend_write.py +4 -4
  9. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/source_discover/config.py +13 -3
  10. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/source_discover/source_discover.py +1 -0
  11. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +32 -28
  12. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/sphinx_extension/source_tracing.py +29 -6
  13. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/LICENSE +0 -0
  14. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/README.md +0 -0
  15. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/__init__.py +0 -0
  16. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/analyse/models.py +0 -0
  17. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/analyse/projects.py +0 -0
  18. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/logger.py +0 -0
  19. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/sphinx_extension/debug.py +0 -0
  20. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.0}/src/sphinx_codelinks/sphinx_extension/html_wrapper.py +0 -0
  21. {sphinx_codelinks-1.0.0 → sphinx_codelinks-1.2.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.0.0
3
+ Version: 1.2.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>
@@ -21,6 +21,9 @@ Requires-Dist: tree-sitter-cpp>=0.23.4
21
21
  Requires-Dist: giturlparse>=0.12.0
22
22
  Requires-Dist: tree-sitter-python>=0.23.6
23
23
  Requires-Dist: tree-sitter~=0.25.1
24
+ Requires-Dist: tree-sitter-c-sharp>=0.23.1
25
+ Requires-Dist: tree-sitter-yaml>=0.7.1
26
+ Requires-Dist: tree-sitter-rust>=0.23.0
24
27
 
25
28
  # Sphinx CodeLinks
26
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sphinx-codelinks"
3
- version = "1.0.0"
3
+ version = "1.2.0"
4
4
  description = "Fast Source Code Traceability for Sphinx-Needs"
5
5
  authors = [{ name = "team useblocks", email = "info@useblocks.com" }]
6
6
  maintainers = [
@@ -27,13 +27,45 @@ dependencies = [
27
27
  # Compiling issue on manylinux aarch64 image, so install from git instead of Pypi
28
28
  # https://github.com/tree-sitter/py-tree-sitter/issues/386#issuecomment-3101430799
29
29
  "tree-sitter~=0.25.1",
30
-
30
+ "tree-sitter-c-sharp>=0.23.1",
31
+ "tree-sitter-yaml>=0.7.1",
32
+ "tree-sitter-rust>=0.23.0",
31
33
  ]
32
34
 
33
35
  [build-system]
34
36
  requires = ["flit_core >=3.4,<4"]
35
37
  build-backend = "flit_core.buildapi"
36
38
 
39
+ [dependency-groups]
40
+ testing = [
41
+ "pytest>=8.2.2",
42
+ "pytest-cov>=5.0.0",
43
+ "pytest-docker>=3.1.2",
44
+ "syrupy>=4.9.1",
45
+ "psutil>=7.0.0",
46
+ "moto ~= 5.0",
47
+ "toml>=0.10.2",
48
+ "furo>=2024.5.6",
49
+ ]
50
+ docs = [
51
+ "furo>=2024.5.6",
52
+ "myst-parser>=4.0.0",
53
+ "sphinx-design>=0.6.1",
54
+ "sphinx-code-tabs>=0.5.5",
55
+ "sphinxcontrib-typer>=0.5.1",
56
+ "sphinxcontrib-video>=0.4.1",
57
+ ]
58
+ mypy = [
59
+ "mypy>=1.12.1",
60
+ "pydantic ~= 2.9",
61
+ "types-docutils",
62
+ "types-Pygments",
63
+ "types-psutil>=7.0.0.20250218",
64
+ "types-jsonschema>=4.23.0.20241208",
65
+ ]
66
+ ruff = ["ruff>=0.8.0"]
67
+ build = ["simple-build>=0.0.2", "shiv>=1.0.8"]
68
+
37
69
  [tool.rye]
38
70
  managed = true
39
71
  dev-dependencies = [
@@ -65,7 +65,11 @@ class SourceAnalyse:
65
65
  self.oneline_needs: list[OneLineNeed] = []
66
66
  self.marked_rst: list[MarkedRst] = []
67
67
  self.all_marked_content: list[NeedIdRefs | OneLineNeed | MarkedRst] = []
68
- self.git_root: Path | None = utils.locate_git_root(self.analyse_config.src_dir)
68
+ # Use explicitly configured git_root if provided, otherwise auto-detect
69
+ if self.analyse_config.git_root is not None:
70
+ self.git_root: Path | None = self.analyse_config.git_root.resolve()
71
+ else:
72
+ self.git_root = utils.locate_git_root(self.analyse_config.src_dir)
69
73
  self.git_remote_url: str | None = (
70
74
  utils.get_remote_url(self.git_root) if self.git_root else None
71
75
  )
@@ -127,6 +131,7 @@ class SourceAnalyse:
127
131
  yield marker, need_ids, row_offset, start_column, end_column
128
132
  row_offset += 1
129
133
 
134
+ # @Extract need ID references from code comments, IMPL_LNK_1, impl, [FE_LNK]
130
135
  def extract_anchors(
131
136
  self,
132
137
  text: str,
@@ -197,20 +202,21 @@ class SourceAnalyse:
197
202
  if not src_comment.source_file:
198
203
  row_offset += 1
199
204
  continue
200
- self.oneline_warnings.append(
201
- AnalyseWarning(
202
- str(src_comment.source_file.filepath),
203
- src_comment.node.start_point.row + row_offset + 1,
204
- resolved.msg,
205
- MarkedContentType.need,
206
- resolved.sub_type.value,
207
- )
205
+ lineno = src_comment.node.start_point.row + row_offset + 1
206
+ warning = AnalyseWarning(
207
+ str(src_comment.source_file.filepath),
208
+ lineno,
209
+ resolved.msg,
210
+ MarkedContentType.need,
211
+ resolved.sub_type.value,
208
212
  )
213
+ self.oneline_warnings.append(warning)
209
214
  row_offset += 1
210
215
  continue
211
216
  yield resolved, row_offset
212
217
  row_offset += 1
213
218
 
219
+ # @Extract one-line traceability needs from comments, IMPL_ONE_1, impl, [FE_DEF, FE_CMT]
214
220
  def extract_oneline_needs(
215
221
  self,
216
222
  text: str,
@@ -258,6 +264,7 @@ class SourceAnalyse:
258
264
  )
259
265
  return oneline_needs
260
266
 
267
+ # @Extract marked reStructuredText blocks from comments, IMPL_MRST_1, impl, [FE_RST_EXTRACTION]
261
268
  def extract_marked_rst(
262
269
  self,
263
270
  text: str,
@@ -322,7 +329,7 @@ class SourceAnalyse:
322
329
  if not filepath:
323
330
  continue
324
331
  tagged_scope: TreeSitterNode | None = utils.find_associated_scope(
325
- src_comment.node
332
+ src_comment.node, self.analyse_config.comment_type
326
333
  )
327
334
  if self.analyse_config.get_need_id_refs:
328
335
  anchors = self.extract_anchors(
@@ -2,12 +2,7 @@ from dataclasses import dataclass
2
2
  from enum import Enum
3
3
  import logging
4
4
 
5
- from sphinx_codelinks.config import (
6
- ESCAPE,
7
- SUPPORTED_COMMENT_TYPES,
8
- UNIX_NEWLINE,
9
- OneLineCommentStyle,
10
- )
5
+ from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
11
6
 
12
7
  # initialize logger
13
8
  logger = logging.getLogger(__name__)
@@ -36,6 +31,7 @@ class OnelineParserInvalidWarning:
36
31
  msg: str
37
32
 
38
33
 
34
+ # @One-line comment parser for traceability markers, IMPL_OLP_1, impl, [FE_DEF, FE_CMT]
39
35
  def oneline_parser( # noqa: PLR0912, PLR0911 # handel warnings
40
36
  oneline: str, oneline_config: OneLineCommentStyle
41
37
  ) -> dict[str, str | list[str] | int] | OnelineParserInvalidWarning | None:
@@ -58,7 +54,7 @@ def oneline_parser( # noqa: PLR0912, PLR0911 # handel warnings
58
54
 
59
55
  # extract the string wrapped by start and end
60
56
  start_idx = start_idx + len(oneline_config.start_sequence)
61
- string = oneline[start_idx:end_idx]
57
+ string = oneline[start_idx:end_idx].strip()
62
58
 
63
59
  # numbers of needs_fields which are required
64
60
  cnt_required_fields = oneline_config.get_cnt_required_fields()
@@ -204,15 +200,3 @@ def is_newline_in_field(field: str) -> bool:
204
200
  Check if the field contains a new line character.
205
201
  """
206
202
  return UNIX_NEWLINE in field
207
-
208
-
209
- def get_file_types(comment_type: str) -> list[str] | None:
210
- """
211
- Get the list of file types to be discovered.
212
- """
213
- file_types = (
214
- list(SUPPORTED_COMMENT_TYPES)
215
- if comment_type in SUPPORTED_COMMENT_TYPES
216
- else None
217
- )
218
- return file_types
@@ -12,6 +12,25 @@ from tree_sitter import Node as TreeSitterNode
12
12
  from sphinx_codelinks.config import UNIX_NEWLINE, CommentCategory
13
13
  from sphinx_codelinks.source_discover.config import CommentType
14
14
 
15
+ # Language-specific node types for scope detection
16
+ SCOPE_NODE_TYPES = {
17
+ # @Python Scope Node Types, IMPL_PY_2, impl, [FE_PY]
18
+ CommentType.python: {"function_definition", "class_definition"},
19
+ # @C and C++ Scope Node Types, IMPL_C_2, impl, [FE_C_SUPPORT, FE_CPP]
20
+ CommentType.cpp: {"function_definition", "class_definition"},
21
+ CommentType.cs: {"method_declaration", "class_declaration", "property_declaration"},
22
+ CommentType.yaml: {"block_mapping_pair", "block_sequence_item", "document"},
23
+ # @Rust Scope Node Types, IMPL_RUST_2, impl, [FE_RUST];
24
+ CommentType.rust: {
25
+ "function_item",
26
+ "struct_item",
27
+ "enum_item",
28
+ "impl_item",
29
+ "trait_item",
30
+ "mod_item",
31
+ },
32
+ }
33
+
15
34
  # initialize logger
16
35
  logger = logging.getLogger(__name__)
17
36
  logger.setLevel(logging.INFO)
@@ -35,6 +54,12 @@ PYTHON_QUERY = """
35
54
  (class_definition (block (expression_statement (string)) @comment))
36
55
  """
37
56
  CPP_QUERY = """(comment) @comment"""
57
+ C_SHARP_QUERY = """(comment) @comment"""
58
+ YAML_QUERY = """(comment) @comment"""
59
+ RUST_QUERY = """
60
+ (line_comment) @comment
61
+ (block_comment) @comment
62
+ """
38
63
 
39
64
 
40
65
  def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
@@ -52,6 +77,7 @@ def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
52
77
  return False
53
78
 
54
79
 
80
+ # @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST]
55
81
  def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
56
82
  if comment_type == CommentType.cpp:
57
83
  import tree_sitter_cpp # noqa: PLC0415
@@ -63,6 +89,21 @@ def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
63
89
 
64
90
  parsed_language = Language(tree_sitter_python.language())
65
91
  query = Query(parsed_language, PYTHON_QUERY)
92
+ elif comment_type == CommentType.cs:
93
+ import tree_sitter_c_sharp # noqa: PLC0415
94
+
95
+ parsed_language = Language(tree_sitter_c_sharp.language())
96
+ query = Query(parsed_language, C_SHARP_QUERY)
97
+ elif comment_type == CommentType.yaml:
98
+ import tree_sitter_yaml # noqa: PLC0415
99
+
100
+ parsed_language = Language(tree_sitter_yaml.language())
101
+ query = Query(parsed_language, YAML_QUERY)
102
+ elif comment_type == CommentType.rust:
103
+ import tree_sitter_rust # noqa: PLC0415
104
+
105
+ parsed_language = Language(tree_sitter_rust.language())
106
+ query = Query(parsed_language, RUST_QUERY)
66
107
  else:
67
108
  raise ValueError(f"Unsupported comment style: {comment_type}")
68
109
  parser = Parser(parsed_language)
@@ -78,6 +119,7 @@ def wrap_read_callable_point(
78
119
  return read_callable_byte_offset
79
120
 
80
121
 
122
+ # @Comment extraction from source code using tree-sitter, IMPL_EXTR_1, impl, [FE_DEF]
81
123
  def extract_comments(
82
124
  src_string: ByteString, parser: Parser, query: Query
83
125
  ) -> list[TreeSitterNode] | None:
@@ -90,39 +132,133 @@ def extract_comments(
90
132
  return captures.get("comment")
91
133
 
92
134
 
93
- def find_enclosing_scope(node: TreeSitterNode) -> TreeSitterNode | None:
135
+ def find_enclosing_scope(
136
+ node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
137
+ ) -> TreeSitterNode | None:
94
138
  """Find the enclosing scope of a comment."""
139
+ scope_types = SCOPE_NODE_TYPES.get(comment_type, SCOPE_NODE_TYPES[CommentType.cpp])
95
140
  current: TreeSitterNode = node
96
141
  while current:
97
- if current.type in {"function_definition", "class_definition"}:
142
+ if current.type in scope_types:
98
143
  return current
99
144
  current: TreeSitterNode | None = current.parent # type: ignore[no-redef] # required for node traversal
100
145
  return None
101
146
 
102
147
 
103
- def find_next_scope(node: TreeSitterNode) -> TreeSitterNode | None:
148
+ def find_next_scope(
149
+ node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
150
+ ) -> TreeSitterNode | None:
104
151
  """Find the next scope of a comment."""
152
+ scope_types = SCOPE_NODE_TYPES.get(comment_type, SCOPE_NODE_TYPES[CommentType.cpp])
105
153
  current: TreeSitterNode = node
106
154
  while current:
107
- if current.type in {"function_definition", "class_definition"}:
155
+ if current.type in scope_types:
108
156
  return current
109
157
  current: TreeSitterNode | None = current.next_named_sibling # type: ignore[no-redef] # required for node traversal
110
158
  if current and current.type == "block":
111
159
  for child in current.named_children:
112
- if child.type in {"function_definition", "class_definition"}:
160
+ if child.type in scope_types:
113
161
  return child
114
162
  return None
115
163
 
116
164
 
117
- def find_associated_scope(node: TreeSitterNode) -> TreeSitterNode | None:
165
+ def _find_yaml_structure_in_block_node(
166
+ block_node: TreeSitterNode,
167
+ ) -> TreeSitterNode | None:
168
+ """Find YAML structure elements within a block_node."""
169
+ for grandchild in block_node.named_children:
170
+ if grandchild.type == "block_mapping":
171
+ for ggchild in grandchild.named_children:
172
+ if ggchild.type == "block_mapping_pair":
173
+ return ggchild
174
+ elif grandchild.type == "block_sequence":
175
+ for ggchild in grandchild.named_children:
176
+ if ggchild.type == "block_sequence_item":
177
+ return ggchild
178
+ return None
179
+
180
+
181
+ def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
182
+ """Find the next YAML structure element after the comment node."""
183
+ current = node.next_named_sibling
184
+ while current:
185
+ if current.type in {
186
+ "block_mapping_pair",
187
+ "block_sequence_item",
188
+ "flow_mapping",
189
+ "flow_sequence",
190
+ }:
191
+ return current
192
+ if current.type == "document":
193
+ for child in current.named_children:
194
+ if child.type == "block_node":
195
+ result = _find_yaml_structure_in_block_node(child)
196
+ if result:
197
+ return result
198
+ if current.type == "block_node":
199
+ result = _find_yaml_structure_in_block_node(current)
200
+ if result:
201
+ return result
202
+ current = current.next_named_sibling
203
+ return None
204
+
205
+
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."""
208
+ comment_row = node.start_point.row
209
+ current = node.prev_named_sibling
210
+
211
+ while current:
212
+ # Check if this sibling ends on the same row as the comment starts
213
+ # This indicates it's an inline comment
214
+ if current.end_point.row == comment_row:
215
+ return current
216
+ # If we find a sibling that ends before the comment row, we can stop
217
+ # as we won't find any siblings on the same row going backwards
218
+ if current.end_point.row < comment_row:
219
+ break
220
+ current = current.prev_named_sibling
221
+
222
+ return None
223
+
224
+
225
+ def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
226
+ """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
227
+ # 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)
229
+ if prev_sibling_same_row:
230
+ return prev_sibling_same_row
231
+
232
+ # If no previous sibling on same row, try to find the next named sibling (structure after the comment)
233
+ structure = find_yaml_next_structure(node)
234
+ if structure:
235
+ return structure
236
+
237
+ # If no next sibling found, traverse up to find parent structure
238
+ parent = node.parent
239
+ while parent:
240
+ if parent.type in {"block_mapping_pair", "block_sequence_item"}:
241
+ return parent
242
+ parent = parent.parent
243
+
244
+ return None
245
+
246
+
247
+ def find_associated_scope(
248
+ node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
249
+ ) -> TreeSitterNode | None:
118
250
  """Find the associated scope of a comment."""
251
+ if comment_type == CommentType.yaml:
252
+ # YAML uses different structure association logic
253
+ return find_yaml_associated_structure(node)
254
+
119
255
  if node.type == CommentCategory.docstring:
120
256
  # Only for python's docstring
121
- return find_enclosing_scope(node)
257
+ return find_enclosing_scope(node, comment_type)
122
258
  # General comments regardless of comment types
123
- associated_scope = find_next_scope(node)
259
+ associated_scope = find_next_scope(node, comment_type)
124
260
  if not associated_scope:
125
- associated_scope = find_enclosing_scope(node)
261
+ associated_scope = find_enclosing_scope(node, comment_type)
126
262
  return associated_scope
127
263
 
128
264
 
@@ -219,6 +355,7 @@ class ExtractedRstType(TypedDict):
219
355
  end_idx: int
220
356
 
221
357
 
358
+ # @Extract reStructuredText blocks embedded in comments, IMPL_RST_1, impl, [FE_RST_EXTRACTION]
222
359
  def extract_rst(
223
360
  text: str, start_marker: str, end_marker: str
224
361
  ) -> ExtractedRstType | None:
@@ -56,7 +56,7 @@ OptQuiet: TypeAlias = Annotated[ # noqa: UP040 # has to be TypeAlias
56
56
 
57
57
 
58
58
  @app.command(no_args_is_help=True)
59
- def analyse(
59
+ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches
60
60
  config: Annotated[
61
61
  Path,
62
62
  typer.Argument(
@@ -90,6 +90,7 @@ def analyse(
90
90
  ] = None,
91
91
  ) -> None:
92
92
  """Analyse marked content in source code."""
93
+ # @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE]
93
94
 
94
95
  data: CodeLinksConfigType = load_config_from_toml(config)
95
96
 
@@ -140,6 +141,12 @@ def analyse(
140
141
  analyse_config.src_files = src_discover.source_paths
141
142
  analyse_config.src_dir = Path(src_discover.src_discover_config.src_dir)
142
143
 
144
+ # git_root shall be relative to the config file's location (like src_dir)
145
+ if analyse_config.git_root is not None:
146
+ analyse_config.git_root = (
147
+ config.parent / analyse_config.git_root
148
+ ).resolve()
149
+
143
150
  analyse_errors = analyse_config.check_fields_configuration()
144
151
  errors.extend(analyse_errors)
145
152
  if errors:
@@ -150,6 +157,15 @@ def analyse(
150
157
  codelinks_config.projects = specifed_project_configs
151
158
  analyse_projects = AnalyseProjects(codelinks_config)
152
159
  analyse_projects.run()
160
+
161
+ # Output warnings to console for CLI users
162
+ for src_analyse in analyse_projects.projects_analyse.values():
163
+ for warning in src_analyse.oneline_warnings:
164
+ logger.warning(
165
+ f"Oneline parser warning in {warning.file_path}:{warning.lineno} "
166
+ f"- {warning.sub_type}: {warning.msg}",
167
+ )
168
+
153
169
  analyse_projects.dump_markers()
154
170
 
155
171
 
@@ -183,6 +199,7 @@ def discover(
183
199
  help="Glob patterns to be included.",
184
200
  ),
185
201
  ] = [], # noqa: B006 # to show the default value on CLI
202
+ # @CLI command to discover source files recursively with gitignore support, IMPL_CLI_DISCOVER, impl, [FE_CLI_DISCOVER]
186
203
  gitignore: Annotated[
187
204
  bool,
188
205
  typer.Option(
@@ -234,6 +251,7 @@ def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires
234
251
  resolve_path=True,
235
252
  ),
236
253
  ],
254
+ # @CLI command to generate needextend RST file from extracted markers, IMPL_CLI_WRITE, impl, [FE_CLI_WRITE]
237
255
  outpath: Annotated[
238
256
  Path,
239
257
  typer.Option(
@@ -18,9 +18,14 @@ from sphinx_codelinks.source_discover.source_discover import SourceDiscover
18
18
  UNIX_NEWLINE = "\n"
19
19
 
20
20
 
21
- COMMENT_MARKERS = {CommentType.cpp: ["//", "/*"], CommentType.python: ["#"]}
21
+ COMMENT_MARKERS = {
22
+ # @Support C and C++ style comments, IMPL_C_1, impl, [FE_C_SUPPORT, FE_CPP]
23
+ CommentType.cpp: ["//", "/*"],
24
+ # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
25
+ CommentType.python: ["#"],
26
+ CommentType.cs: ["//", "/*", "///"],
27
+ }
22
28
  ESCAPE = "\\"
23
- SUPPORTED_COMMENT_TYPES = {"c", "h", "cpp", "hpp", "py"}
24
29
 
25
30
 
26
31
  class CommentCategory(str, Enum):
@@ -47,7 +52,7 @@ class NeedIdRefsConfig:
47
52
  @classmethod
48
53
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
49
54
  _field = next(_field for _field in fields(cls) if _field.name is name)
50
- if _field.metadata is not MISSING and "schema" in _field.metadata:
55
+ if _field.metadata and "schema" in _field.metadata:
51
56
  return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
52
57
  return None
53
58
 
@@ -86,7 +91,7 @@ class MarkedRstConfig:
86
91
  @classmethod
87
92
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
88
93
  _field = next(_field for _field in fields(cls) if _field.name is name)
89
- if _field.metadata is not MISSING and "schema" in _field.metadata:
94
+ if _field.metadata and "schema" in _field.metadata:
90
95
  return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
91
96
  return None
92
97
 
@@ -219,14 +224,14 @@ class OneLineCommentStyle:
219
224
  @classmethod
220
225
  def get_required_fields(cls, name: str) -> list[str] | None:
221
226
  _field = next(_field for _field in fields(cls) if _field.name is name)
222
- if _field.metadata is not MISSING:
227
+ if _field.metadata:
223
228
  return cast(list[str], _field.metadata["required_fields"])
224
229
  return None
225
230
 
226
231
  @classmethod
227
232
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
228
233
  _field = next(_field for _field in fields(cls) if _field.name is name)
229
- if _field.metadata is not MISSING and "schema" in _field.metadata:
234
+ if _field.metadata and "schema" in _field.metadata:
230
235
  return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
231
236
  return None
232
237
 
@@ -300,6 +305,7 @@ class AnalyseSectionConfigType(TypedDict, total=False):
300
305
  get_oneline_needs: bool
301
306
  get_rst: bool
302
307
  outdir: str
308
+ git_root: str
303
309
  need_id_refs: NeedIdRefsConfigType
304
310
  marked_rst: MarkedRstConfigType
305
311
  oneline_comment_style: OneLineCommentStyleType
@@ -314,6 +320,7 @@ class SourceAnalyseConfigType(TypedDict, total=False):
314
320
  get_need_id_refs: bool
315
321
  get_oneline_needs: bool
316
322
  get_rst: bool
323
+ git_root: Path | None
317
324
  need_id_refs_config: NeedIdRefsConfig
318
325
  marked_rst_config: MarkedRstConfig
319
326
  oneline_comment_style: OneLineCommentStyle
@@ -356,6 +363,12 @@ class SourceAnalyseConfig:
356
363
  get_rst: bool = field(default=False, metadata={"schema": {"type": "boolean"}})
357
364
  """Whether to extract rst texts from comments"""
358
365
 
366
+ git_root: Path | None = field(
367
+ default=None, metadata={"schema": {"type": ["string", "null"]}}
368
+ )
369
+ """Explicit path to the Git repository root. If not set, it will be auto-detected
370
+ by traversing parent directories. Useful for Bazel builds or deeply nested configs."""
371
+
359
372
  need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig)
360
373
  """Configuration for extracting need id references from comments."""
361
374
 
@@ -370,7 +383,7 @@ class SourceAnalyseConfig:
370
383
  @classmethod
371
384
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
372
385
  _field = next(_field for _field in fields(cls) if _field.name is name)
373
- if _field.metadata is not MISSING and "schema" in _field.metadata:
386
+ if _field.metadata and "schema" in _field.metadata:
374
387
  return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
375
388
  return None
376
389
 
@@ -566,7 +579,7 @@ class CodeLinksConfig:
566
579
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
567
580
  """Get the schema for a config item."""
568
581
  _field = next(field for field in fields(cls) if field.name is name)
569
- if _field.metadata is not MISSING and "schema" in _field.metadata:
582
+ if _field.metadata and "schema" in _field.metadata:
570
583
  return _field.metadata["schema"] # type: ignore[no-any-return]
571
584
  return None
572
585
 
@@ -760,9 +773,11 @@ def convert_analyse_config(
760
773
  if config_dict:
761
774
  for k, v in config_dict.items():
762
775
  if k not in {"online_comment_style", "need_id_refs", "marked_rst"}:
763
- analyse_config_dict[k] = ( # type: ignore[literal-required] # dynamical assignment
764
- Path(v) if k == "src_dic" and isinstance(v, str) else v
765
- )
776
+ # Convert string paths to Path objects
777
+ if k in {"src_dir", "git_root"} and isinstance(v, str):
778
+ analyse_config_dict[k] = Path(v) # type: ignore[literal-required]
779
+ else:
780
+ analyse_config_dict[k] = v # type: ignore[literal-required] # dynamical assignment
766
781
 
767
782
  # Get oneline_comment_style configuration
768
783
  oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get(
@@ -791,6 +806,15 @@ def convert_analyse_config(
791
806
  if src_discover:
792
807
  analyse_config_dict["src_files"] = src_discover.source_paths
793
808
  analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
809
+ try:
810
+ analyse_config_dict["comment_type"] = CommentType(
811
+ src_discover.src_discover_config.comment_type
812
+ )
813
+ except ValueError:
814
+ # If invalid comment_type, keep the string value
815
+ # Validation will catch this error later
816
+ comment_type_str: str = src_discover.src_discover_config.comment_type
817
+ analyse_config_dict["comment_type"] = comment_type_str # type: ignore[typeddict-item]
794
818
 
795
819
  return SourceAnalyseConfig(**analyse_config_dict)
796
820
 
@@ -853,4 +877,13 @@ def generate_project_configs(
853
877
  )
854
878
  analyse_config = convert_analyse_config(analyse_section_config)
855
879
  analyse_config.get_oneline_needs = True # force to get oneline_need
880
+ # Copy comment_type from source_discover_config to analyse_config
881
+ try:
882
+ analyse_config.comment_type = CommentType(
883
+ source_discover_config.comment_type
884
+ )
885
+ except ValueError:
886
+ # If invalid comment_type, keep the string value
887
+ # Validation will catch this error later
888
+ analyse_config.comment_type = source_discover_config.comment_type # type: ignore[assignment]
856
889
  project_config["analyse_config"] = analyse_config
@@ -1,7 +1,7 @@
1
- """Covert the generated JSON file created by CodeLinks anaylse to need-extend in RST."""
1
+ """Convert the generated JSON file created by CodeLinks analyse to need-extend in RST."""
2
2
 
3
3
  from collections import deque
4
- from dataclasses import MISSING, dataclass, field, fields
4
+ from dataclasses import dataclass, field, fields
5
5
  from os import linesep
6
6
  from string import Template
7
7
  from typing import Any, TypedDict, cast
@@ -105,7 +105,7 @@ class MarkedContentSchema:
105
105
  @classmethod
106
106
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
107
107
  _field = next(_field for _field in fields(cls) if _field.name is name)
108
- if _field.metadata is not MISSING and "schema" in _field.metadata:
108
+ if _field.metadata and "schema" in _field.metadata:
109
109
  return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
110
110
  return None
111
111
 
@@ -165,7 +165,7 @@ def convert_marked_content(
165
165
  remote_url_field: str = "remote-url",
166
166
  title: str | None = None,
167
167
  ) -> tuple[list[str], list[str]]:
168
- """Convert marked objects extracted by anaylse CLI to needextend in RST"""
168
+ """Convert marked objects extracted by analyse CLI to needextend in RST"""
169
169
  errors = []
170
170
  needextend_texts: list[str] = []
171
171
  intersted_objs = [
@@ -1,16 +1,26 @@
1
- from dataclasses import MISSING, dataclass, field, fields
1
+ from dataclasses import dataclass, field, fields
2
2
  from enum import Enum
3
3
  from pathlib import Path
4
4
  from typing import Any, Required, TypedDict, cast
5
5
 
6
6
  from jsonschema import ValidationError, validate
7
7
 
8
- COMMENT_FILETYPE = {"cpp": ["c", "cpp", "h", "hpp"], "python": ["py"]}
8
+ COMMENT_FILETYPE = {
9
+ "cpp": ["c", "ci", "cpp", "cc", "cxx", "h", "hpp", "hxx", "hh", "ihl"],
10
+ "python": ["py"],
11
+ "cs": ["cs"],
12
+ "yaml": ["yml", "yaml"],
13
+ "rust": ["rs"],
14
+ }
9
15
 
10
16
 
11
17
  class CommentType(str, Enum):
12
18
  python = "python"
13
19
  cpp = "cpp"
20
+ cs = "cs"
21
+ yaml = "yaml"
22
+ # @Support Rust style comments, IMPL_RUST_1, impl, [FE_RUST];
23
+ rust = "rust"
14
24
 
15
25
 
16
26
  class SourceDiscoverSectionConfigType(TypedDict, total=False):
@@ -73,7 +83,7 @@ class SourceDiscoverConfig:
73
83
  @classmethod
74
84
  def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
75
85
  _field = next(_field for _field in fields(cls) if _field.name is name)
76
- if _field.metadata is not MISSING and "schema" in _field.metadata:
86
+ if _field.metadata and "schema" in _field.metadata:
77
87
  return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any]
78
88
  return None
79
89
 
@@ -13,6 +13,7 @@ from sphinx_codelinks.source_discover.config import (
13
13
  )
14
14
 
15
15
 
16
+ # @Source code file discovery with gitignore support, IMPL_DISC_1, impl, [FE_DISCOVERY, FE_CLI_DISCOVER]
16
17
  class SourceDiscover:
17
18
  def __init__(self, src_discover_config: SourceDiscoverConfig):
18
19
  self.src_discover_config = src_discover_config
@@ -33,6 +33,15 @@ else:
33
33
  logger = logging.getLogger(__name__)
34
34
 
35
35
 
36
+ def get_rel_path(doc_path: Path, code_path: Path, base_dir: Path) -> tuple[Path, Path]:
37
+ """Get the relative path from the document to the source code file and vice versa."""
38
+ doc_depth = len(doc_path.parents) - 1
39
+ src_rel_path = Path(*[".."] * doc_depth) / code_path.relative_to(base_dir)
40
+ code_depth = len(code_path.relative_to(base_dir).parents) - 1
41
+ doc_rel_path = Path(*[".."] * code_depth) / doc_path
42
+ return src_rel_path, doc_rel_path.with_suffix(".html")
43
+
44
+
36
45
  def generate_str_link_name(
37
46
  oneline_need: OneLineNeed,
38
47
  target_filepath: Path,
@@ -67,13 +76,12 @@ class SourceTracing(nodes.General, nodes.Element):
67
76
 
68
77
 
69
78
  class SourceTracingDirective(SphinxDirective):
70
- required_arguments = 1
79
+ required_arguments = 0
71
80
  optional_arguments = 0
72
81
  final_argument_whitespace = True
73
82
  # this enables content in the directive
74
- has_content = True
83
+ has_content = False
75
84
  option_spec: ClassVar[dict[str, Callable[[str], str]] | None] = {
76
- "id": directives.unchanged_required,
77
85
  "project": directives.unchanged_required,
78
86
  "file": directives.unchanged_required,
79
87
  "directory": directives.unchanged_required,
@@ -84,7 +92,6 @@ class SourceTracingDirective(SphinxDirective):
84
92
  validate_option(self.options)
85
93
 
86
94
  project = self.options["project"]
87
- title = self.arguments[0]
88
95
  # get source tracing config
89
96
  src_trace_sphinx_config = CodeLinksConfig.from_sphinx(self.env.config)
90
97
 
@@ -99,7 +106,6 @@ class SourceTracingDirective(SphinxDirective):
99
106
  # the directory where the source files are copied to
100
107
  target_dir = out_dir / src_dir.name
101
108
 
102
- extra_options = {"project": project}
103
109
  source_files = self.get_src_files(self.options, src_dir, src_discover_config)
104
110
 
105
111
  # add source files into the dependency
@@ -110,23 +116,16 @@ class SourceTracingDirective(SphinxDirective):
110
116
  analyse_config = src_trace_conf["analyse_config"]
111
117
  analyse_config.src_dir = src_dir
112
118
  analyse_config.src_files = source_files
119
+ # git_root shall be relative to the config file's location (if provided)
120
+ if analyse_config.git_root:
121
+ conf_dir = Path(self.env.app.confdir)
122
+ if src_trace_sphinx_config.config_from_toml:
123
+ src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml)
124
+ conf_dir = conf_dir / src_trace_toml_path.parent
125
+ analyse_config.git_root = (conf_dir / analyse_config.git_root).resolve()
113
126
  src_analyse = SourceAnalyse(analyse_config)
114
127
  src_analyse.run()
115
128
 
116
- needs = []
117
-
118
- # create the need for src-trace directive
119
- src_trace_need = add_need(
120
- app=self.env.app, # The Sphinx application object
121
- state=self.state, # The docutils state object
122
- docname=self.env.docname, # The current document name
123
- lineno=self.lineno, # The line number where the directive is used
124
- need_type="srctrace", # The type of the need
125
- title=title, # The title of the need
126
- **extra_options,
127
- )
128
- needs.extend(src_trace_need)
129
-
130
129
  dirs = {
131
130
  "src_dir": src_dir,
132
131
  "out_dir": out_dir,
@@ -145,7 +144,7 @@ class SourceTracingDirective(SphinxDirective):
145
144
  to_remove_str = to_remove_str.replace("\\", "\\\\")
146
145
  self.env.config.needs_string_links[local_url_field] = {
147
146
  "regex": r"^(?P<value>.+?)\.[^\.]+#L(?P<lineno>\d+)",
148
- "link_url": ("file://{{value}}.html#L-{{lineno}}"),
147
+ "link_url": ("{{value}}.html#L-{{lineno}}"),
149
148
  "link_name": f"{{{{value | replace('{to_remove_str}', '')}}}}#L{{{{lineno}}}}",
150
149
  "options": [local_url_field],
151
150
  }
@@ -180,18 +179,16 @@ class SourceTracingDirective(SphinxDirective):
180
179
  remote_url_field,
181
180
  dirs,
182
181
  )
183
- if rendered_needs:
184
- needs.extend(rendered_needs)
185
182
 
186
183
  # for post-processing of need links
187
184
  # https://github.com/useblocks/sphinx-needs/issues/1210
188
185
  add_doc(self.env, self.env.docname)
189
186
 
190
- return needs
187
+ return rendered_needs
191
188
 
192
189
  def get_src_files(
193
190
  self,
194
- extra_options: dict[str, str],
191
+ additional_options: dict[str, str],
195
192
  src_dir: Path,
196
193
  src_discover_config: SourceDiscoverConfig,
197
194
  ) -> list[Path]:
@@ -201,14 +198,14 @@ class SourceTracingDirective(SphinxDirective):
201
198
  file: str = self.options["file"]
202
199
  filepath = src_dir / file
203
200
  source_files.append(filepath.resolve())
204
- extra_options["file"] = file
201
+ additional_options["file"] = file
205
202
  else:
206
203
  directory = self.options.get("directory")
207
204
  if directory is None:
208
205
  # when neither "file" and "directory" are given, the project root dir is by default
209
206
  directory = "./"
210
207
  else:
211
- extra_options["directory"] = directory
208
+ additional_options["directory"] = directory
212
209
  dir_path = src_dir / directory
213
210
  # create a new config for the specified directory
214
211
  src_discover = SourceDiscoverConfig(
@@ -259,7 +256,7 @@ class SourceTracingDirective(SphinxDirective):
259
256
  # mapping between lineno and need link in docs for local url
260
257
 
261
258
  # The link to the documentation page for the source file
262
- docs_href = f"{dirs['out_dir'] / self.env.docname}.html"
259
+
263
260
  if local_url_field:
264
261
  # copy files to _build/html
265
262
  target_filepath.parent.mkdir(parents=True, exist_ok=True)
@@ -268,8 +265,15 @@ class SourceTracingDirective(SphinxDirective):
268
265
  remote_link_name = None
269
266
  if local_url_field:
270
267
  # generate link name
268
+ # calculate the relative path from the current doc to the target file
269
+ local_rel_path, docs_href = get_rel_path(
270
+ Path(self.env.docname), target_filepath, dirs["out_dir"]
271
+ )
271
272
  local_link_name = generate_str_link_name(
272
- oneline_need, target_filepath, dirs, local=True
273
+ oneline_need,
274
+ local_rel_path,
275
+ dirs,
276
+ local=True,
273
277
  )
274
278
  if remote_url_field:
275
279
  remote_link_name = generate_str_link_name(
@@ -35,7 +35,34 @@ from sphinx_codelinks.sphinx_extension.html_wrapper import html_wrapper
35
35
  logger = logging.getLogger(__name__)
36
36
 
37
37
 
38
+ def _check_sphinx_needs_dependency(app: Sphinx) -> bool:
39
+ """Check if sphinx-needs is actually loaded as an extension."""
40
+ # Check if sphinx-needs is in the loaded extensions
41
+ if "sphinx_needs" not in app.extensions:
42
+ error_msg = (
43
+ "sphinx-codelinks requires sphinx-needs to be loaded as an extension.\n"
44
+ "Please ensure 'sphinx_needs' is properly installed and added to your extensions list in conf.py:\n"
45
+ " extensions = ['sphinx_needs', 'sphinx_codelinks', ...]\n"
46
+ f"Currently loaded extensions: {list(app.extensions.keys())}\n"
47
+ f"Configured extensions: {app.config.extensions}"
48
+ )
49
+ logger.error(error_msg)
50
+ return False
51
+ return True
52
+
53
+
38
54
  def setup(app: Sphinx) -> dict[str, Any]: # type: ignore[explicit-any]
55
+ # Check if sphinx-needs is available and properly configured
56
+ if not _check_sphinx_needs_dependency(app):
57
+ logger.error(
58
+ "Failed to initialize sphinx-codelinks due to missing sphinx-needs dependency"
59
+ )
60
+ return {
61
+ "version": "builtin",
62
+ "parallel_read_safe": True,
63
+ "parallel_write_safe": True,
64
+ }
65
+
39
66
  app.add_node(SourceTracing)
40
67
  app.add_directive("src-trace", SourceTracingDirective)
41
68
  CodeLinksConfig.add_config_values(app)
@@ -69,7 +96,7 @@ def add_custom_css( # type: ignore[explicit-any]
69
96
  app: Sphinx,
70
97
  pagename: str,
71
98
  templatename: str,
72
- context: dict[str, Any],
99
+ _context: dict[str, Any],
73
100
  _doctree: Any,
74
101
  ) -> None:
75
102
  target_htmls = {
@@ -78,11 +105,7 @@ def add_custom_css( # type: ignore[explicit-any]
78
105
  }
79
106
 
80
107
  if pagename in target_htmls and templatename == "page.html":
81
- if "css_files" not in context:
82
- context["css_files"] = []
83
- context["css_files"].append(
84
- "_static/source_tracing/ub_sct.css"
85
- ) # Add the custom CSS file to the context
108
+ app.add_css_file("_static/source_tracing/ub_sct.css")
86
109
 
87
110
 
88
111
  def generate_code_page(