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