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