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