1import json
  2from collections import deque
  3from os import linesep
  4from pathlib import Path
  5from typing import Annotated, TypeAlias, cast
  6
  7import typer
  8
  9from sphinx_codelinks.analyse.projects import AnalyseProjects
 10from sphinx_codelinks.config import (
 11    CodeLinksConfig,
 12    CodeLinksConfigType,
 13    CodeLinksProjectConfigType,
 14    anchor_preproc_paths,
 15    generate_project_configs,
 16    get_rst_ignored_warning,
 17    git_root_problem,
 18    git_root_warning,
 19    load_codelinks_table,
 20)
 21from sphinx_codelinks.logger import configure_cli, get_logger, logger
 22from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content
 23from sphinx_codelinks.source_discover.config import (
 24    CommentType,
 25    SourceDiscoverConfig,
 26    SourceDiscoverConfigType,
 27)
 28from sphinx_codelinks.source_discover.source_discover import SourceDiscover
 29from ub_project import ProjectConfigError, anchor
 30
 31#: the package logger: a warning goes to stderr once ``configure_cli`` ran
 32analysis_logger = get_logger(__name__)
 33
 34app = typer.Typer(
 35    no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
 36)
 37write_app = typer.Typer(
 38    help="Export marked content to other formats", no_args_is_help=True
 39)
 40app.add_typer(write_app, name="write", rich_help_panel="Sub-menus")
 41
 42WRITE_RST_DEPRECATED = (
 43    "`write rst` is deprecated: the build attaches `@need-ids:` references itself "
 44    "(field `code_url`); it will be removed in sphinx-codelinks 2.0.0"
 45)
 46
 47OptVerbose: TypeAlias = Annotated[  # has to be TypeAlias
 48    bool,
 49    typer.Option(
 50        ...,
 51        "-v",
 52        "--verbose",
 53        is_flag=True,
 54        help="Show debug information",
 55        rich_help_panel="Logging",
 56    ),
 57]
 58OptQuiet: TypeAlias = Annotated[  # has to be TypeAlias
 59    bool,
 60    typer.Option(
 61        ...,
 62        "-q",
 63        "--quiet",
 64        is_flag=True,
 65        help="Only show errors and warnings",
 66        rich_help_panel="Logging",
 67    ),
 68]
 69
 70
 71@app.command(no_args_is_help=True)
 72def analyse(  # for CLI, so it needs the branches
 73    config: Annotated[
 74        Path,
 75        typer.Argument(
 76            help="The toml config file",
 77            show_default=False,
 78            dir_okay=False,
 79            file_okay=True,
 80            exists=True,
 81        ),
 82    ],
 83    projects: Annotated[
 84        list[str] | None,
 85        typer.Option(
 86            "--project",
 87            "-p",
 88            help="Specify the project name of the config. If not specified, take all",
 89            show_default=True,
 90        ),
 91    ] = None,
 92    outdir: Annotated[
 93        Path | None,
 94        typer.Option(
 95            "--outdir",
 96            "-o",
 97            help="The output directory. When given, this overwrites the config's outdir",
 98            show_default=True,
 99            dir_okay=True,
100            file_okay=False,
101            exists=True,
102        ),
103    ] = None,
104    verbose: OptVerbose = False,
105    quiet: OptQuiet = False,
106) -> None:
107    """Analyse marked content in source code."""
[docs]108    # @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE]
109    configure_cli(verbose, quiet)
110
111    data: CodeLinksConfigType = load_config_from_toml(config)
112
113    try:
114        codelinks_config = CodeLinksConfig(**data)
115        generate_project_configs(codelinks_config.projects)
116    except TypeError as e:
117        raise typer.BadParameter(str(e)) from e
118
119    errors: deque[str] = deque()
120    if outdir:
121        codelinks_config.outdir = outdir
122
123    project_errors: list[str] = []
124    if projects:
125        for project in projects:
126            if project not in codelinks_config.projects:
127                if not project_errors:
128                    project_errors.append("The following projects are not found:")
129                project_errors.append(project)
130    if project_errors:
131        raise typer.BadParameter(f"{linesep.join(project_errors)}")
132
133    specifed_project_configs: dict[str, CodeLinksProjectConfigType] = {}
134    for project, _config in codelinks_config.projects.items():
135        if projects and project not in projects:
136            continue
137        if (ignored := get_rst_ignored_warning(project, _config)) is not None:
138            analysis_logger.warning(ignored, subtype="config")
139        # Get source_discover configuration
140        src_discover_config = _config["source_discover_config"]
141
142        src_discover_errors = src_discover_config.check_schema()
143
144        if src_discover_errors:
145            errors.appendleft("Invalid source discovery configuration:")
146            errors.extend(src_discover_errors)
147        if errors:
148            raise typer.BadParameter(f"{linesep.join(errors)}")
149
150        # src dir shall be relevant to the config file's location
151        src_discover_config.src_dir = anchor(
152            src_discover_config.src_dir, config.parent
153        ).resolve()
154
155        src_discover = SourceDiscover(src_discover_config)
156
157        # Init source analyse config
158        analyse_config = _config["analyse_config"]
159        analyse_config.src_files = src_discover.source_paths
160        analyse_config.src_dir = Path(src_discover.src_discover_config.src_dir)
161
162        # git_root shall be relative to the config file's location (like src_dir)
163        if analyse_config.git_root is not None:
164            analyse_config.git_root = anchor(
165                analyse_config.git_root, config.parent
166            ).resolve()
167            # the build's rule: a git_root that does not contain src_dir is ignored,
168            # and the analysis detects the repository from src_dir (#2062)
169            problem = git_root_problem(analyse_config.git_root, analyse_config.src_dir)
170            if problem is not None:
171                analysis_logger.warning(
172                    git_root_warning(project, problem), subtype="git_root"
173                )
174                analyse_config.git_root = None
175
176        # preprocessor compile_commands / include dirs are relative to the config
177        # file's location too (like src_dir / git_root).
178        if analyse_config.preprocessor is not None:
179            analyse_config.preprocessor = anchor_preproc_paths(
180                analyse_config.preprocessor, config.parent
181            )
182
183        analyse_errors = analyse_config.check_fields_configuration()
184        errors.extend(analyse_errors)
185        if errors:
186            raise typer.BadParameter(f"{linesep.join(errors)}")
187
188        specifed_project_configs[project] = {"analyse_config": analyse_config}
189
190    codelinks_config.projects = specifed_project_configs
191    analyse_projects = AnalyseProjects(codelinks_config)
192    analyse_projects.run()
193
194    # Output warnings to console for CLI users: one shape for every kind
195    for src_analyse in analyse_projects.projects_analyse.values():
196        for warning in src_analyse.warnings:
197            logger.warning(
198                f"Analyse warning in {warning.file_path}:{warning.lineno} "
199                f"- {warning.sub_type}: {warning.msg}",
200            )
201
202    analyse_projects.dump_markers()
203
204
205@app.command(no_args_is_help=True)
206def discover(  # CLI command requires multiple parameters
207    src_dir: Annotated[
208        Path,
209        typer.Argument(
210            ...,
211            help="Root directory for discovery",
212            show_default=False,
213            dir_okay=True,
214            file_okay=False,
215            exists=True,
216            resolve_path=True,
217        ),
218    ],
219    exclude: Annotated[
220        list[str] | None,
221        typer.Option(
222            "--excludes",
223            "-e",
224            help=(
225                "Glob patterns to be excluded. When omitted, defaults to "
226                "the comment-type-derived default (non-empty only for "
227                "--comment-type ts); passing this option one or more times "
228                "replaces that default outright."
229            ),
230            show_default=False,
231        ),
232    ] = None,
233    include: Annotated[
234        list[str],
235        typer.Option(
236            "--includes",
237            "-i",
238            help="Glob patterns to be included.",
239        ),
240    ] = [],  # noqa: B006   # to show the default value on CLI
[docs]241    # @CLI command to discover source files recursively with gitignore support, IMPL_CLI_DISCOVER, impl, [FE_CLI_DISCOVER]
242    gitignore: Annotated[
243        bool,
244        typer.Option(
245            help="Respect .gitignore files in the given directory and its parents"
246        ),
247    ] = True,
248    follow_links: Annotated[
249        bool,
250        typer.Option(help="Follow symbolic links during file discovery"),
251    ] = False,
252    comment_type: Annotated[
253        CommentType,
254        typer.Option(
255            "--comment-type",
256            "-c",
257            help="The relevant file extensions which use the specified the comment type will be discovered.",
258        ),
259    ] = CommentType.cpp,
260) -> None:
261    """Discover the filepaths from the given root directory."""
262    # a file outside the directory is warned about: on stderr, whatever ran before
263    configure_cli()
264
265    src_discover_dict: SourceDiscoverConfigType = {
266        "src_dir": src_dir,
267        "include": include,
268        "gitignore": gitignore,
269        "follow_links": follow_links,
270        "comment_type": comment_type,
271    }
272    # Only pass "exclude" through when the user actually gave -e/--excludes;
273    # otherwise leave it out so SourceDiscoverConfig resolves its own
274    # comment_type-derived default, same as the Sphinx extension / `analyse`
275    # path does for a project's TOML config that omits `exclude`.
276    if exclude is not None:
277        src_discover_dict["exclude"] = exclude
278
279    src_discover_config = SourceDiscoverConfig(**src_discover_dict)
280
281    errors = src_discover_config.check_schema()
282    if errors:
283        raise typer.BadParameter(f"{linesep.join(errors)}")
284
285    source_discover = SourceDiscover(src_discover_config)
286    typer.echo(f"{len(source_discover.source_paths)} files discovered")
287    for file_path in source_discover.source_paths:
288        typer.echo(file_path)
289
290
291@write_app.command("rst", no_args_is_help=True)
292def write_rst(  # for CLI, so it takes as many as it requires
293    jsonpath: Annotated[
294        Path,
295        typer.Argument(
296            ...,
297            help="Path of the JSON file which contains the extracted markers",
298            show_default=False,
299            dir_okay=False,
300            file_okay=True,
301            exists=True,
302            resolve_path=True,
303        ),
304    ],
[docs]305    # @CLI command to generate needextend RST file from extracted markers, IMPL_CLI_WRITE, impl, [FE_CLI_WRITE]
306    outpath: Annotated[
307        Path,
308        typer.Option(
309            "--outpath",
310            "-o",
311            help="The output path for generated rst file",
312            show_default=True,
313            dir_okay=False,
314            file_okay=True,
315            exists=False,
316        ),
317    ] = Path("needextend.rst"),
318    remote_url_field: Annotated[
319        str,
320        typer.Option(
321            "--remote-url-field",
322            "-r",
323            help="The field name for the remote url",
324            show_default=True,
325        ),
326    ] = "remote_url",  # to show default value in this CLI
327    title: Annotated[
328        str | None,
329        typer.Option(
330            "--title",
331            "-t",
332            help="Give the title to the generated RST file",
333            show_default=True,
334        ),
335    ] = None,  # to show default value in this CLI
336    verbose: OptVerbose = False,
337    quiet: OptQuiet = False,
338) -> None:
339    """Generate needextend.rst from the extracted obj in JSON (deprecated).
340
341    The Sphinx build attaches ``@need-ids:`` references to the needs they name itself,
342    in each project's ``ref_url_field`` (default ``code_url``).
343    """
344    configure_cli(verbose, quiet)
345    typer.echo(WRITE_RST_DEPRECATED, err=True)
346    try:
347        with jsonpath.open("r") as f:
348            marked_content = json.load(f)
349    except Exception as e:
350        raise typer.BadParameter(
351            f"Failed to load marked content from {jsonpath}: {e}"
352        ) from e
353
354    marked_objs: list[MarkedObjType] = [
355        obj for objs in marked_content.values() for obj in objs
356    ]
357
358    needextend_texts, errors = convert_marked_content(
359        marked_objs, remote_url_field, title
360    )
361    if errors:
362        raise typer.BadParameter(
363            f"Errors occurred during conversion: {linesep.join(errors)}"
364        )
365    with outpath.open("w") as f:
366        f.writelines(needextend_texts)
367    typer.echo(f"Generated {outpath}")
368
369
370def load_config_from_toml(toml_file: Path) -> CodeLinksConfigType:
371    try:
372        codelink_dict = load_codelinks_table(toml_file)
373    except ProjectConfigError as error:
374        # ub-project's message already names the file and says what is wrong
375        raise typer.BadParameter(str(error)) from error
376    except Exception as error:
377        # the TOML parser can also fail with an exception ``load_toml`` does not wrap (a
378        # RecursionError on a pathologically nested file): still a usage error
379        raise typer.BadParameter(
380            f"Failed to load CodeLinks configuration from {toml_file}: {error}"
381        ) from error
382
383    if not codelink_dict:
384        raise typer.BadParameter(f"No 'codelinks' section found in {toml_file}")
385
386    return cast(CodeLinksConfigType, codelink_dict)
387
388
389if __name__ == "__main__":
390    app()