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