1from dataclasses import dataclass
2
3from sphinx_codelinks.analyse.models import WarningSubTypeEnum
4from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
5
6
7@dataclass
8class OnelineParserInvalidWarning:
9 """Invalid oneline comments."""
10
11 sub_type: WarningSubTypeEnum
12 msg: str
13
14
[docs] 15# @One-line comment parser for traceability markers, IMPL_OLP_1, impl, [FE_DEF, FE_CMT]
16def oneline_parser( # handel warnings
17 oneline: str, oneline_config: OneLineCommentStyle
18) -> dict[str, str | list[str] | int] | OnelineParserInvalidWarning | None:
19 """
20 Extract the string from the custom one-line comment style with the following steps.
21
22 - Locate the start and end sequences
23 - extract the string between them
24 - return ``None`` for an empty content, whatever the start sequence
25 - on a one-character start sequence, return ``None`` for a content without the
26 field separator where more than one field is required: not a marker, and not a
27 warning either. The rule is ubCode's, and so are its words: "when a
28 single-character start_sequence (like `@`) matches but the content has no field
29 delimiter at all, this is likely a stray match
30 (e.g., Doxygen `@param`, `@brief`, `@return`) rather than a real marker.
31 Treating these as NoMatch avoids noisy TooFewFields warnings on codebases with
32 Doxygen annotations. Multi-character sequences (like `[[`) are specific enough
33 that a missing delimiter should still produce a warning."
34 - apply custom_split to split the strings into a list of fields by `field_split_char`
35 - check the number of required fields and the max number of the given fields
36 - split the strings located in the field with `type: list[str]` to a list of string
37 - introduce the default values to those fields which are not given
38 """
39 # find indices start and end char
40 start_idx = oneline.find(oneline_config.start_sequence)
41 end_idx = oneline.rfind(oneline_config.end_sequence)
42 if start_idx == -1 or end_idx == -1:
43 # start or end sequences do not exist
44 return None
45
46 # A marker whose end sequence is the newline extends to the end of the
47 # line, so an unanchored start sequence would swallow trailing prose. Anchor
48 # such a marker to the start of the comment content: everything preceding
49 # the start sequence must be comment decoration (`//`, `#`, `*`, ...) and
50 # whitespace. A word character before it means the start sequence is part of
51 # free-form prose (e.g. `// see @author, ...`) and the line is ignored
52 # (issue #88). Explicitly-bounded markers (e.g. `[[ ... ]]`) are
53 # self-delimiting and may appear anywhere, so they are exempt.
54 if oneline_config.end_sequence == UNIX_NEWLINE and any(
55 char.isalnum() for char in oneline[:start_idx]
56 ):
57 return None
58
59 # extract the string wrapped by start and end
60 start_idx = start_idx + len(oneline_config.start_sequence)
61 string = oneline[start_idx:end_idx].strip()
62
63 # numbers of needs_fields which are required
64 cnt_required_fields = oneline_config.get_cnt_required_fields()
65
66 if not string:
67 # nothing between the start and end sequences: not a marker either
68 return None
69
70 # a documentation tag such as `@param x`, not a marker (see the docstring)
71 if (
72 len(oneline_config.start_sequence) == 1
73 and oneline_config.field_split_char not in string
74 and cnt_required_fields > 1
75 ):
76 return None
77 # indices of the field which has type:list[str]
78 positions_list_str = oneline_config.get_pos_list_str()
79
80 min_fields = cnt_required_fields
81 max_fields = len(oneline_config.needs_fields)
82
83 string_fields = [
84 _field.strip(" ")
85 for _field in custom_split(
86 string, oneline_config.field_split_char, positions_list_str
87 )
88 ]
89 if len(string_fields) < min_fields:
90 return OnelineParserInvalidWarning(
91 sub_type=WarningSubTypeEnum.too_few_fields,
92 msg=f"{len(string_fields)} given fields, minimum is {min_fields}",
93 )
94
95 if len(string_fields) > max_fields:
96 return OnelineParserInvalidWarning(
97 sub_type=WarningSubTypeEnum.too_many_fields,
98 msg=f"{len(string_fields)} given fields, maximum is {max_fields}",
99 )
100 resolved: dict[str, str | list[str] | int] = {}
101 for idx in range(len(oneline_config.needs_fields)):
102 field_name: str = oneline_config.needs_fields[idx]["name"]
103 if len(string_fields) > idx:
104 # given fields
105 if is_newline_in_field(string_fields[idx]):
106 # the case where the field contains a new line character
107 return OnelineParserInvalidWarning(
108 sub_type=WarningSubTypeEnum.newline_in_field,
109 msg=f"Field '{field_name}' contains a newline character",
110 )
111 if oneline_config.needs_fields[idx]["type"] == "str":
112 resolved[field_name] = string_fields[idx]
113 elif oneline_config.needs_fields[idx]["type"] == "list[str]":
114 # find the indices of "[" and "]"
115 list_start_idx = string_fields[idx].find("[")
116 list_end_idx = string_fields[idx].rfind("]")
117 if list_start_idx == -1 or list_end_idx == -1:
118 # brackets are not found
119 return OnelineParserInvalidWarning(
120 sub_type=WarningSubTypeEnum.missing_square_brackets,
121 msg=f"Field '{field_name}' with 'type': '{oneline_config.needs_fields[idx]['type']}' must be given with '[]' brackets",
122 )
123
124 if list_start_idx != 0 or list_end_idx != len(string_fields[idx]) - 1:
125 # brackets are found but not at the beginning and the end
126 return OnelineParserInvalidWarning(
127 sub_type=WarningSubTypeEnum.not_start_or_end_with_square_brackets,
128 msg=f"Field '{field_name}' with 'type': '{oneline_config.needs_fields[idx]['type']}' must start with '[' and end with ']'",
129 )
130
131 string_items = string_fields[idx][list_start_idx + 1 : list_end_idx]
132
133 if not string_items.strip():
134 # the case where the empty string ("") or only spaces between "[" "]"
135 resolved[field_name] = []
136 else:
137 items = [_item.strip() for _item in custom_split(string_items, ",")]
138 resolved[field_name] = [item.strip() for item in items]
139 else:
140 # for not given fields, introduce the default
141 default = oneline_config.needs_fields[idx].get("default")
142 if default is None:
143 continue
144 resolved[field_name] = default
145
146 resolved["start_column"] = start_idx
147 resolved["end_column"] = end_idx
148 return resolved
149
150
151def custom_split(
152 string: str, delimiter: str, positions_list_str: list[int] | None = None
153) -> list[str]:
154 """
155 A string shall be split with the following conditions:
156
157 - To use special chars in literal , escape ('\') must be used
158 - String shall be split by the given delimiter
159 - In a field with `type: str`:
160 - Special chars are delimiter, '\', '[' and ']'
161 - In a field with `type: list[str]`:
162 - Special chars are only '[' and ']'
163
164 When the string is given without any fields with `type: list[str]` (positions_list_str=None),
165 it's considered as it is in a field with `type: str`.
166 """
167 if positions_list_str is None:
168 positions_list_str = []
169 escape_chars = [delimiter, "[", "]", ESCAPE]
170 field = [] # a list of string for a field
171 fields: list[str] = [] # a list of string which contains
172 leading_escape = False
173 expect_closing_bracket = False
174
175 for char in string:
176 # +1 to locate the current field position
177 current_field_idx = len(fields) + 1
178 is_list_str_field = current_field_idx in positions_list_str
179
180 if leading_escape:
181 if char not in escape_chars:
182 # leading escape is considered as a literal
183 field.append(ESCAPE)
184 field.append(char)
185 leading_escape = False
186 continue
187
188 if char == ESCAPE and not is_list_str_field:
189 leading_escape = True
190 continue
191
192 if char == delimiter:
193 if is_list_str_field and expect_closing_bracket:
194 # delimiter occurs in the field with type:list[str]
195 field.append(char)
196 else:
197 fields.append("".join(field))
198 field = []
199 continue
200
201 if is_list_str_field:
202 if char == "[":
203 expect_closing_bracket = True
204 if char == "]":
205 expect_closing_bracket = False
206
207 field.append(char)
208
209 # add last field
210 fields.append("".join(field))
211 return fields
212
213
214def is_newline_in_field(field: str) -> bool:
215 """
216 Check if the field contains a new line character.
217 """
218 return UNIX_NEWLINE in field