1from dataclasses import dataclass
2from enum import Enum
3
4from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
5
6
7class WarningSubTypeEnum(str, Enum):
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( # noqa: PLR0912, PLR0911 # 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 - 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 # indices of the field which has type:list[str]
66 positions_list_str = oneline_config.get_pos_list_str()
67
68 min_fields = cnt_required_fields
69 max_fields = len(oneline_config.needs_fields)
70
71 string_fields = [
72 _field.strip(" ")
73 for _field in custom_split(
74 string, oneline_config.field_split_char, positions_list_str
75 )
76 ]
77 if len(string_fields) < min_fields:
78 return OnelineParserInvalidWarning(
79 sub_type=WarningSubTypeEnum.too_few_fields,
80 msg=f"{len(string_fields)} given fields. They shall be more than {min_fields}",
81 )
82
83 if len(string_fields) > max_fields:
84 return OnelineParserInvalidWarning(
85 sub_type=WarningSubTypeEnum.too_many_fields,
86 msg=f"{len(string_fields)} given fields. They shall be less than {max_fields}",
87 )
88 resolved: dict[str, str | list[str] | int] = {}
89 for idx in range(len(oneline_config.needs_fields)):
90 field_name: str = oneline_config.needs_fields[idx]["name"]
91 if len(string_fields) > idx:
92 # given fields
93 if is_newline_in_field(string_fields[idx]):
94 # the case where the field contains a new line character
95 return OnelineParserInvalidWarning(
96 sub_type=WarningSubTypeEnum.newline_in_field,
97 msg=f"Field {field_name} has newline character. It is not allowed",
98 )
99 if oneline_config.needs_fields[idx]["type"] == "str":
100 resolved[field_name] = string_fields[idx]
101 elif oneline_config.needs_fields[idx]["type"] == "list[str]":
102 # find the indices of "[" and "]"
103 list_start_idx = string_fields[idx].find("[")
104 list_end_idx = string_fields[idx].rfind("]")
105 if list_start_idx == -1 or list_end_idx == -1:
106 # brackets are not found
107 return OnelineParserInvalidWarning(
108 sub_type=WarningSubTypeEnum.missing_square_brackets,
109 msg=f"Field {field_name} with 'type': '{oneline_config.needs_fields[idx]['type']}' must be given with '[]' brackets",
110 )
111
112 if list_start_idx != 0 or list_end_idx != len(string_fields[idx]) - 1:
113 # brackets are found but not at the beginning and the end
114 return OnelineParserInvalidWarning(
115 sub_type=WarningSubTypeEnum.not_start_or_end_with_square_brackets,
116 msg=f"Field {field_name} with 'type': '{oneline_config.needs_fields[idx]['type']}' must start with '[' and end with ']'",
117 )
118
119 string_items = string_fields[idx][list_start_idx + 1 : list_end_idx]
120
121 if not string_items.strip():
122 # the case where the empty string ("") or only spaces between "[" "]"
123 resolved[field_name] = []
124 else:
125 items = [_item.strip() for _item in custom_split(string_items, ",")]
126 resolved[field_name] = [item.strip() for item in items]
127 else:
128 # for not given fields, introduce the default
129 default = oneline_config.needs_fields[idx].get("default")
130 if default is None:
131 continue
132 resolved[field_name] = default
133
134 resolved["start_column"] = start_idx
135 resolved["end_column"] = end_idx
136 return resolved
137
138
139def custom_split(
140 string: str, delimiter: str, positions_list_str: list[int] | None = None
141) -> list[str]:
142 """
143 A string shall be split with the following conditions:
144
145 - To use special chars in literal , escape ('\') must be used
146 - String shall be split by the given delimiter
147 - In a field with `type: str`:
148 - Special chars are delimiter, '\', '[' and ']'
149 - In a field with `type: list[str]`:
150 - Special chars are only '[' and ']'
151
152 When the string is given without any fields with `type: list[str]` (positions_list_str=None),
153 it's considered as it is in a field with `type: str`.
154 """
155 if positions_list_str is None:
156 positions_list_str = []
157 escape_chars = [delimiter, "[", "]", ESCAPE]
158 field = [] # a list of string for a field
159 fields: list[str] = [] # a list of string which contains
160 leading_escape = False
161 expect_closing_bracket = False
162
163 for char in string:
164 # +1 to locate the current field position
165 current_field_idx = len(fields) + 1
166 is_list_str_field = current_field_idx in positions_list_str
167
168 if leading_escape:
169 if char not in escape_chars:
170 # leading escape is considered as a literal
171 field.append(ESCAPE)
172 field.append(char)
173 leading_escape = False
174 continue
175
176 if char == ESCAPE and not is_list_str_field:
177 leading_escape = True
178 continue
179
180 if char == delimiter:
181 if is_list_str_field and expect_closing_bracket:
182 # delimiter occurs in the field with type:list[str]
183 field.append(char)
184 else:
185 fields.append("".join(field))
186 field = []
187 continue
188
189 if is_list_str_field:
190 if char == "[":
191 expect_closing_bracket = True
192 if char == "]":
193 expect_closing_bracket = False
194
195 field.append(char)
196
197 # add last field
198 fields.append("".join(field))
199 return fields
200
201
202def is_newline_in_field(field: str) -> bool:
203 """
204 Check if the field contains a new line character.
205 """
206 return UNIX_NEWLINE in field