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