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