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