1import re
  2from dataclasses import dataclass
  3
  4from sphinx_codelinks.analyse.models import WarningSubTypeEnum
  5from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
  6
  7# Tags of Python docstring conventions (Epydoc, Doxygen, Sphinx field names)
  8# that a one-line start sequence such as ``@`` also matches: ``@param a: the
  9# first, thing`` would otherwise become a need with the id ``thing``.
 10PYTHON_DOCSTRING_TAGS = frozenset(
 11    {
 12        "arg",
 13        "argument",
 14        "attention",
 15        "author",
 16        "brief",
 17        "bug",
 18        "cvar",
 19        "deprecated",
 20        "details",
 21        "except",
 22        "exception",
 23        "invariant",
 24        "ivar",
 25        "key",
 26        "keyword",
 27        "kwarg",
 28        "kwparam",
 29        "note",
 30        "param",
 31        "parameter",
 32        "post",
 33        "pre",
 34        "raise",
 35        "raises",
 36        "retval",
 37        "return",
 38        "returns",
 39        "rtype",
 40        "see",
 41        "since",
 42        "summary",
 43        "throw",
 44        "throws",
 45        "todo",
 46        "tparam",
 47        "type",
 48        "var",
 49        "vartype",
 50        "version",
 51        "warning",
 52        "yield",
 53        "yields",
 54        "ytype",
 55    }
 56)
 57
 58
 59#: What follows the tag of an Epydoc field: ``:`` directly (``@return: text``), or
 60#: spaces or tabs, one word of characters other than space, tab and ``:``, and ``:``,
 61#: as in ``@param a: text``. Locale-free: ubCode spells the same.
 62_FIELD_SHAPE = re.compile(r":|[ \t]+[^ \t:]+:")
 63
 64
 65def docstring_tag(line: str, start_sequence: str) -> str | None:
 66    """The docstring tag a docstring line starts with, if any.
 67
 68    The line starts with one when, after comment decoration and whitespace (no
 69    alphanumeric character), it holds ``start_sequence`` directly followed by a
 70    tag of :data:`PYTHON_DOCSTRING_TAGS` in the Epydoc field shape: ``:``
 71    directly, or spaces or tabs, one word without space, tab or ``:``, and ``:``.
 72    A line such as ``@todo fix it, IMPL_1, impl`` has no field shape and is left to
 73    the one-line parser.
 74    """
 75    if not start_sequence:
 76        return None
 77    idx = line.find(start_sequence)
 78    if idx == -1 or any(char.isalnum() for char in line[:idx]):
 79        return None
 80    rest = line[idx + len(start_sequence) :]
 81    word_end = 0
 82    while word_end < len(rest) and rest[word_end].isalpha():
 83        word_end += 1
 84    tag = rest[:word_end]
 85    if tag not in PYTHON_DOCSTRING_TAGS or not _FIELD_SHAPE.match(rest, word_end):
 86        return None
 87    return tag
 88
 89
 90# Block tags of JSDoc, TSDoc and TypeScript's JSDoc support, and the inline tags that can
 91# start a line, which a one-line start sequence such as ``@`` also matches: ``@param a
 92# the first, thing`` would otherwise become a need with the id ``thing``.
 93JSDOC_TAGS = frozenset(
 94    {
 95        "abstract",
 96        "access",
 97        "alias",
 98        "alpha",
 99        "arg",
100        "argument",
101        "async",
102        "augments",
103        "author",
104        "beta",
105        "borrows",
106        "callback",
107        "class",
108        "classdesc",
109        "const",
110        "constant",
111        "constructor",
112        "constructs",
113        "copyright",
114        "decorator",
115        "default",
116        "defaultValue",
117        "defaultvalue",
118        "deprecated",
119        "desc",
120        "description",
121        "emits",
122        "enum",
123        "event",
124        "eventProperty",
125        "example",
126        "exception",
127        "experimental",
128        "exports",
129        "extends",
130        "external",
131        "file",
132        "fileoverview",
133        "fires",
134        "func",
135        "function",
136        "generator",
137        "global",
138        "hideconstructor",
139        "host",
140        "ignore",
141        "implements",
142        "import",
143        "inheritDoc",
144        "inheritdoc",
145        "inner",
146        "instance",
147        "interface",
148        "internal",
149        "jsx",
150        "jsxFrag",
151        "jsxImportSource",
152        "jsxRuntime",
153        "kind",
154        "label",
155        "lends",
156        "license",
157        "link",
158        "linkcode",
159        "linkplain",
160        "listens",
161        "member",
162        "memberof",
163        "method",
164        "mixes",
165        "mixin",
166        "modifies",
167        "module",
168        "name",
169        "namespace",
170        "overload",
171        "override",
172        "overview",
173        "package",
174        "packageDocumentation",
175        "param",
176        "private",
177        "privateRemarks",
178        "prop",
179        "property",
180        "protected",
181        "public",
182        "readonly",
183        "remarks",
184        "requires",
185        "return",
186        "returns",
187        "satisfies",
188        "sealed",
189        "see",
190        "since",
191        "static",
192        "summary",
193        "template",
194        "this",
195        "throws",
196        "todo",
197        "tutorial",
198        "type",
199        "typeParam",
200        "typedef",
201        "var",
202        "variation",
203        "version",
204        "virtual",
205        "yield",
206        "yields",
207    }
208)
209
210
211#: What ends a JSDoc tag: a space, a tab, a ``{type}``, the ``}`` of an inline tag, or the
212#: end of the line. Locale-free: ubCode spells the same.
213_JSDOC_TAG_END = re.compile(r"[ \t\n{}]|$")
214
215
216def jsdoc_tag(line: str, start_sequence: str) -> str | None:
217    """The JSDoc tag a line of a JSDoc comment starts with, if any.
218
219    The line starts with one when, after comment decoration and whitespace (no
220    alphanumeric character), it holds ``start_sequence`` directly followed by a tag of
221    :data:`JSDOC_TAGS` that ends there: at a space, a tab, a ``{``, a ``}`` or the end of
222    the line (``@param a text``, ``@param{number} a``, ``{@link Foo}``, ``@returns``).
223    """
224    if not start_sequence:
225        return None
226    idx = line.find(start_sequence)
227    if idx == -1 or any(char.isalnum() for char in line[:idx]):
228        return None
229    rest = line[idx + len(start_sequence) :]
230    word_end = 0
231    while word_end < len(rest) and rest[word_end].isalpha():
232        word_end += 1
233    tag = rest[:word_end]
234    if tag not in JSDOC_TAGS or not _JSDOC_TAG_END.match(rest, word_end):
235        return None
236    return tag
237
238
239@dataclass
240class OnelineParserInvalidWarning:
241    """Invalid oneline comments."""
242
243    sub_type: WarningSubTypeEnum
244    msg: str
245
246
[docs]247# @One-line comment parser for traceability markers, IMPL_OLP_1, impl, [FE_DEF, FE_CMT]
248def oneline_parser(  # handel warnings
249    oneline: str, oneline_config: OneLineCommentStyle
250) -> dict[str, str | list[str] | int] | OnelineParserInvalidWarning | None:
251    """
252    Extract the string from the custom one-line comment style with the following steps.
253
254    - Locate the start and end sequences
255    - extract the string between them
256    - return ``None`` for an empty content, whatever the start sequence
257    - on a one-character start sequence, return ``None`` for a content without the
258      field separator where more than one field is required: not a marker, and not a
259      warning either. The rule is ubCode's, and so are its words: "when a
260      single-character start_sequence (like `@`) matches but the content has no field
261      delimiter at all, this is likely a stray match
262      (e.g., Doxygen `@param`, `@brief`, `@return`) rather than a real marker.
263      Treating these as NoMatch avoids noisy TooFewFields warnings on codebases with
264      Doxygen annotations. Multi-character sequences (like `[[`) are specific enough
265      that a missing delimiter should still produce a warning."
266    - apply custom_split to split the strings into a list of fields by `field_split_char`
267    - check the number of required fields and the max number of the given fields
268    - split the strings located in the field with `type: list[str]` to a list of string
269    - introduce the default values to those fields which are not given
270    """
271    # find indices start and end char
272    start_idx = oneline.find(oneline_config.start_sequence)
273    end_idx = oneline.rfind(oneline_config.end_sequence)
274    if start_idx == -1 or end_idx == -1:
275        # start or end sequences do not exist
276        return None
277
278    # A marker whose end sequence is the newline extends to the end of the
279    # line, so an unanchored start sequence would swallow trailing prose. Anchor
280    # such a marker to the start of the comment content: everything preceding
281    # the start sequence must be comment decoration (`//`, `#`, `*`, ...) and
282    # whitespace. A word character before it means the start sequence is part of
283    # free-form prose (e.g. `// see @author, ...`) and the line is ignored
284    # (issue #88). Explicitly-bounded markers (e.g. `[[ ... ]]`) are
285    # self-delimiting and may appear anywhere, so they are exempt.
286    if oneline_config.end_sequence == UNIX_NEWLINE and any(
287        char.isalnum() for char in oneline[:start_idx]
288    ):
289        return None
290
291    # extract the string wrapped by start and end
292    start_idx = start_idx + len(oneline_config.start_sequence)
293    string = oneline[start_idx:end_idx].strip()
294
295    # numbers of needs_fields which are required
296    cnt_required_fields = oneline_config.get_cnt_required_fields()
297
298    if not string:
299        # nothing between the start and end sequences: not a marker either
300        return None
301
302    # a documentation tag such as `@param x`, not a marker (see the docstring)
303    if (
304        len(oneline_config.start_sequence) == 1
305        and oneline_config.field_split_char not in string
306        and cnt_required_fields > 1
307    ):
308        return None
309    # indices of the field which has type:list[str]
310    positions_list_str = oneline_config.get_pos_list_str()
311
312    min_fields = cnt_required_fields
313    max_fields = len(oneline_config.needs_fields)
314
315    string_fields = [
316        _field.strip(" ")
317        for _field in custom_split(
318            string, oneline_config.field_split_char, positions_list_str
319        )
320    ]
321    if len(string_fields) < min_fields:
322        return OnelineParserInvalidWarning(
323            sub_type=WarningSubTypeEnum.too_few_fields,
324            msg=f"{len(string_fields)} given fields, minimum is {min_fields}",
325        )
326
327    if len(string_fields) > max_fields:
328        return OnelineParserInvalidWarning(
329            sub_type=WarningSubTypeEnum.too_many_fields,
330            msg=f"{len(string_fields)} given fields, maximum is {max_fields}",
331        )
332    resolved: dict[str, str | list[str] | int] = {}
333    for idx in range(len(oneline_config.needs_fields)):
334        field_name: str = oneline_config.needs_fields[idx]["name"]
335        if len(string_fields) > idx:
336            # given fields
337            if is_newline_in_field(string_fields[idx]):
338                # the case where the field contains a new line character
339                return OnelineParserInvalidWarning(
340                    sub_type=WarningSubTypeEnum.newline_in_field,
341                    msg=f"Field '{field_name}' contains a newline character",
342                )
343            if oneline_config.needs_fields[idx]["type"] == "str":
344                resolved[field_name] = string_fields[idx]
345            elif oneline_config.needs_fields[idx]["type"] == "list[str]":
346                # find the indices of "[" and "]"
347                list_start_idx = string_fields[idx].find("[")
348                list_end_idx = string_fields[idx].rfind("]")
349                if list_start_idx == -1 or list_end_idx == -1:
350                    # brackets are not  found
351                    return OnelineParserInvalidWarning(
352                        sub_type=WarningSubTypeEnum.missing_square_brackets,
353                        msg=f"Field '{field_name}' with 'type': '{oneline_config.needs_fields[idx]['type']}' must be given with '[]' brackets",
354                    )
355
356                if list_start_idx != 0 or list_end_idx != len(string_fields[idx]) - 1:
357                    # brackets are found but not at the beginning and the end
358                    return OnelineParserInvalidWarning(
359                        sub_type=WarningSubTypeEnum.not_start_or_end_with_square_brackets,
360                        msg=f"Field '{field_name}' with 'type': '{oneline_config.needs_fields[idx]['type']}' must start with '[' and end with ']'",
361                    )
362
363                string_items = string_fields[idx][list_start_idx + 1 : list_end_idx]
364
365                if not string_items.strip():
366                    # the case where the empty string ("") or only spaces between "[" "]"
367                    resolved[field_name] = []
368                else:
369                    items = [_item.strip() for _item in custom_split(string_items, ",")]
370                    resolved[field_name] = [item.strip() for item in items]
371        else:
372            # for not given fields, introduce the default
373            default = oneline_config.needs_fields[idx].get("default")
374            if default is None:
375                continue
376            resolved[field_name] = default
377
378    resolved["start_column"] = start_idx
379    resolved["end_column"] = end_idx
380    return resolved
381
382
383def custom_split(
384    string: str, delimiter: str, positions_list_str: list[int] | None = None
385) -> list[str]:
386    """
387    A string shall be split with the following conditions:
388
389    - To use special chars in literal , escape ('\') must be used
390    - String shall be split by the given delimiter
391    - In a field with `type: str`:
392        - Special chars are delimiter, '\', '[' and ']'
393    - In a field with `type: list[str]`:
394        - Special chars are only '[' and ']'
395
396    When the string is given without any fields with `type: list[str]` (positions_list_str=None),
397    it's considered as it is in a field with `type: str`.
398    """
399    if positions_list_str is None:
400        positions_list_str = []
401    escape_chars = [delimiter, "[", "]", ESCAPE]
402    field = []  # a list of string for a field
403    fields: list[str] = []  # a list of string which contains
404    leading_escape = False
405    expect_closing_bracket = False
406
407    for char in string:
408        # +1 to locate the current field position
409        current_field_idx = len(fields) + 1
410        is_list_str_field = current_field_idx in positions_list_str
411
412        if leading_escape:
413            if char not in escape_chars:
414                # leading escape is considered as a literal
415                field.append(ESCAPE)
416            field.append(char)
417            leading_escape = False
418            continue
419
420        if char == ESCAPE and not is_list_str_field:
421            leading_escape = True
422            continue
423
424        if char == delimiter:
425            if is_list_str_field and expect_closing_bracket:
426                # delimiter occurs in the field with type:list[str]
427                field.append(char)
428            else:
429                fields.append("".join(field))
430                field = []
431            continue
432
433        if is_list_str_field:
434            if char == "[":
435                expect_closing_bracket = True
436            if char == "]":
437                expect_closing_bracket = False
438
439        field.append(char)
440
441    # add last field
442    fields.append("".join(field))
443    return fields
444
445
446def is_newline_in_field(field: str) -> bool:
447    """
448    Check if the field contains a new line character.
449    """
450    return UNIX_NEWLINE in field