diff --git a/AGENTS.md b/AGENTS.md index 1f1bcea..ad39fdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,9 +219,10 @@ src/nf_docs/ `render_to_directory()`. Factory function `get_renderer(format)` returns the appropriate renderer. HTML uses a single Jinja2 template with inline CSS (Tailwind) and JavaScript for search. Adding a new output format means updating `RENDERERS` in `renderers/__init__.py`, - `SINGLE_FILE_OUTPUT_POLICY` and (if it writes a directory) `DIRECTORY_FORMATS` in `output.py`, the - CLI's `--format` choices, and tests. Format aliases live only in `output.FORMAT_ALIASES`; - `get_renderer()` resolves through `normalize_format()` so there is one alias table. + `SINGLE_FILE_OUTPUT_POLICY` and (if it writes a directory) `DIRECTORY_FORMATS` in `output.py`, and + tests. The CLI's `--format` choices come from `output.supported_formats()`, so they follow + automatically. Format aliases live only in `output.FORMAT_ALIASES`; `get_renderer()` resolves + through `normalize_format()` so there is one alias table. - **Config parsing**: `config_parser.py` shells out to `nextflow config -flat`. `nextflow_env.py` sets an isolated `NXF_HOME` so this doesn't pollute the user's Nextflow state. - **Two entry points**: `cli.py` (rich-click) and `api.py` (library). All source/output path and diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa61c5..a4fdcba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,26 @@ - `PipelineExtractor` accepts a `config=` argument, so callers can supply an `NfDocsConfig` instead of relying on the global instance - New "Python API" documentation page +- Every `NfDocsConfig` option now takes effect. `ignore_input_prefixes`, `include_hidden_params`, + `max_readme_length` and `exclude_patterns` were documented but never consulted; `default_format` + was ignored in favour of a hardcoded `html` +- Restored the `nf-docs config` command (`--path`, `--show-example`, `--init`), which had been + removed by accident along with the config wiring ### Fixed +- `strip_readme_badges` is consulted again when parsing a README. It was wired up when the option + was introduced, then dropped by an unrelated revert in the same release +- A parameter excluded by `include_hidden_params` or `ignore_input_prefixes` no longer reappears in + the Configuration section +- A config option set to the wrong type falls back to its default with a warning, rather than + failing somewhere downstream. `default_format: 3` used to be an uncaught `AttributeError`, + `max_readme_length: "lots"` silently dropped the README, and `exclude_patterns: "tests"` sent the + language server one exclusion per character +- `NfDocsConfig.from_dict()` copies its list defaults, so mutating a returned config no longer + changes `DEFAULT_CONFIG` for every later caller +- `default_format` is excluded from the extraction cache key. It only picks the CLI's output format, + so changing it no longer evicts every cached pipeline - The extraction cache is now keyed on the configuration as well as the pipeline contents. Previously a CLI run (which loads `~/.config/nf-docs/config.yaml`) and a library call (which uses defaults) shared a cache entry for the same unchanged pipeline and returned each other's results @@ -23,6 +40,22 @@ ### Changed +- `include_hidden_params` now defaults to `true`, matching the behaviour nf-docs has always had of + showing hidden schema parameters. Set it to `false` to leave them out + +### Upgrading + +Six config options previously had no effect. If you already have a +`~/.config/nf-docs/config.yaml`, its settings now apply for the first time, and your output may +change: + +- `include_hidden_params: false` — parameters marked `hidden` in `nextflow_schema.json` disappear + from the documentation. This is the option doing what it says, but it is a visible change; delete + the line (or set `true`) to keep the previous output +- `ignore_input_prefixes`, `max_readme_length`, `exclude_patterns` — likewise now honoured +- `default_format` — `nf-docs generate` without `-f` uses it instead of always producing HTML + +Run `nf-docs config` to see the settings currently in effect. - Output path policy (format aliases, single-module detection, default filenames) moved from `cli.py` into a new `nf_docs.output` module so the CLI and the Python API share one implementation - Library callers now get `NfDocsConfig()` defaults rather than the user's diff --git a/docs/python-api.md b/docs/python-api.md index 2649ad1..ebf025a 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -206,9 +206,14 @@ config = nf_docs.NfDocsConfig(ignore_config_prefixes=["genomes.", "test."]) nf_docs.extract("./my_pipeline", config=config) ``` -`ignore_config_prefixes` drops matching parameters from the Configuration section, and defaults to -`["genomes."]` for nf-core pipelines. The other `NfDocsConfig` fields exist but aren't yet wired -into extraction, so don't rely on them. +The fields, their defaults and what each one does are listed under +[Configuration file](running.md#configuration-file). Two things specific to the API: + +- `default_format` is read only by `nf-docs generate`. Setting it on a config you pass to + `extract()` or `generate()` does nothing — the API takes its format as an argument. +- Passing a config that isn't an `NfDocsConfig` (say, a plain dict) isn't supported. Build one with + `NfDocsConfig(...)`, or `NfDocsConfig.from_dict(...)` if you have a mapping and want the same + wrong-type checking `load_config()` applies. ## Caching diff --git a/docs/running.md b/docs/running.md index 6146cef..be76a90 100644 --- a/docs/running.md +++ b/docs/running.md @@ -11,7 +11,7 @@ standalone module or subworkflow — see [Document a single module](#document-a- | Option | Description | | ------------------- | -------------------------------------------------------------------- | -| `--format`, `-f` | Output format: `html` (default), `markdown`, `table`, `json`, `yaml` | +| `--format`, `-f` | Output format: `markdown`, `table`, `json`, `yaml`, `html` (default) | | `--output`, `-o` | Output file or directory (default: `docs/`) | | `--title`, `-t` | Custom title | | `--no-cache` | Force fresh extraction | @@ -111,15 +111,41 @@ nf-docs clear-cache /path/to/pipeline # Clear the entire extraction cache nf-docs clear-cache --all + +# Show configuration, its file path, or create a starter config file +nf-docs config +nf-docs config --path +nf-docs config --init ``` +## Configuration file + +nf-docs reads an optional config file at `~/.config/nf-docs/config.yaml` (respects +`$XDG_CONFIG_HOME`). It works without one. `nf-docs config --init` writes a commented starter file, +and `nf-docs config` shows the settings currently in effect. + +| Option | Default | Effect | +| ------------------------ | -------------- | ----------------------------------------------------------------------------------- | +| `ignore_config_prefixes` | `["genomes."]` | Drop matching parameters from the Configuration section | +| `ignore_input_prefixes` | `[]` | Drop matching parameters from the Parameters section | +| `include_hidden_params` | `true` | Set `false` to leave out parameters marked `hidden` in `nextflow_schema.json` | +| `max_readme_length` | `0` | Trim README source text to this many characters, on a line boundary. `0` = no limit | +| `strip_readme_badges` | `true` | Set `false` to keep the badge lines below a README's title | +| `exclude_patterns` | `[]` | Extra paths the Language Server skips when indexing, added to the built-in ones | +| `default_format` | `html` | Format `nf-docs generate` uses when `-f/--format` isn't given | + +An option set to the wrong type falls back to its default with a warning. These settings apply to +the CLI; the [Python API](python-api.md#configuration) uses defaults unless you pass a config +explicitly. + ## Caching nf-docs caches extraction results to avoid re-running the Language Server on every invocation. Cached data is stored in `~/.cache/nf-docs/` (respects `$XDG_CACHE_HOME`). -The cache is keyed by pipeline path, a SHA-256 hash of all pipeline source files, and the nf-docs -version, so it is automatically invalidated whenever files change or nf-docs is upgraded. +The cache is keyed by pipeline path, a SHA-256 hash of all pipeline source files, the nf-docs +version, and the configuration options that affect extraction — so it is automatically invalidated +whenever files change, nf-docs is upgraded, or you edit a setting that changes the output. Use `--no-cache` to skip the cache for a single run, or `nf-docs clear-cache` to remove entries manually. diff --git a/src/nf_docs/cli.py b/src/nf_docs/cli.py index c5b947e..14f3947 100644 --- a/src/nf_docs/cli.py +++ b/src/nf_docs/cli.py @@ -10,6 +10,7 @@ from pathlib import Path import rich_click as click +import yaml from rich.console import Console from rich.logging import RichHandler from rich.progress import ( @@ -23,7 +24,7 @@ from nf_docs import __version__ from nf_docs.api import extract as extract_pipeline -from nf_docs.config import load_config +from nf_docs.config import get_config_path, get_example_config, load_config from nf_docs.extractor import ExtractionError, PipelineExtractor from nf_docs.lsp_client import LSPError from nf_docs.output import ( @@ -34,6 +35,7 @@ resolve_single_file_path, resolve_source, streams_by_default, + supported_formats, ) from nf_docs.progress import ProgressUpdate from nf_docs.renderers import get_renderer @@ -183,9 +185,12 @@ def _display_path(path: Path) -> str: "--format", "-f", "output_format", - type=click.Choice(["json", "yaml", "markdown", "md", "html", "table"], case_sensitive=False), - default="html", - help="Output format: json, yaml, markdown (or md), html, table (default: html)", + type=click.Choice(supported_formats(), case_sensitive=False), + default=None, + help=( + f"Output format: {', '.join(supported_formats())} " + "(default: the config file's default_format, otherwise html)" + ), ) @click.option( "--output", @@ -253,7 +258,13 @@ def generate( """ setup_logging(verbose) - output_format = normalize_format(output_format) + # The CLI honours the user's ~/.config/nf-docs/config.yaml; the Python API + # deliberately does not (see nf_docs.api.extract). + user_config = load_config() + + # An explicit --format wins; otherwise the config file's default_format, + # which load_config() has already checked is a format we can render. + output_format = normalize_format(output_format or user_config.default_format) # Decide single-file vs directory mode up front. A directory holding only a # module-style main.nf is auto-detected as a single module. @@ -266,10 +277,6 @@ def generate( if single_file_mode: logging.getLogger("nf_docs").info("Generating single module documentation") - # The CLI honours the user's ~/.config/nf-docs/config.yaml; the Python API - # deliberately does not (see nf_docs.api.extract). - user_config = load_config() - try: with ExtractionProgressDisplay(console) as progress_display: pipeline = extract_pipeline( @@ -586,5 +593,87 @@ def clear_cache(clear_all: bool, pipeline_path: Path | None) -> None: raise SystemExit(1) +@main.command() +@click.option( + "--init", + "init_config", + is_flag=True, + help="Create an example config file at the default location", +) +@click.option( + "--show-example", + is_flag=True, + help="Print an example config file to stdout", +) +@click.option( + "--path", + is_flag=True, + help="Print the config file path", +) +def config(init_config: bool, show_example: bool, path: bool) -> None: + """ + Show or manage nf-docs configuration. + + Without options, shows the current configuration values. + + Examples: + + # Show current config + nf-docs config + + # Show config file path + nf-docs config --path + + # Print example config + nf-docs config --show-example + + # Create config file with defaults + nf-docs config --init + """ + # Route load_config()'s warnings through the console, so a broken config + # file says so here rather than reaching stderr unformatted. + setup_logging(verbose=False) + config_path = get_config_path() + + if path: + # click.echo, not console.print: rich hard-wraps at the terminal width, + # which would split a long path across lines in `$(nf-docs config --path)`. + click.echo(str(config_path)) + return + + if show_example: + click.echo(get_example_config()) + return + + if init_config: + if config_path.exists(): + console.print(f"[yellow]Config file already exists at {config_path}[/yellow]") + console.print("Edit it directly or delete it first to recreate.") + return + + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(get_example_config(), encoding="utf-8") + console.print(f"[green]Created config file at {config_path}[/green]") + return + + # Default: show current config + console.print(f"[bold]Config file:[/bold] {config_path}") + if config_path.exists(): + console.print("[dim] (file exists)[/dim]") + else: + console.print("[dim] (using defaults - file does not exist)[/dim]") + + # Load before printing the header: a file that fails to parse warns here, + # so "settings in effect" isn't read as "contents of that file". + settings = load_config().to_dict() + + console.print() + console.print("[bold]Settings in effect:[/bold]") + + # Dump as YAML so what's printed is exactly what you'd write in the file. + # click.echo rather than console.print, so rich doesn't read "[]" as markup. + click.echo(yaml.safe_dump(settings, sort_keys=False)) + + if __name__ == "__main__": main() diff --git a/src/nf_docs/config.py b/src/nf_docs/config.py index af1ade0..2b5ca91 100644 --- a/src/nf_docs/config.py +++ b/src/nf_docs/config.py @@ -12,12 +12,15 @@ import json import logging import os +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml +from nf_docs.output import is_supported_format, supported_formats + logger = logging.getLogger(__name__) # Default configuration values @@ -27,17 +30,58 @@ # Prefixes for config parameters to ignore in inputs "ignore_input_prefixes": [], # Whether to include hidden parameters in documentation - "include_hidden_params": False, - # Default output format + "include_hidden_params": True, + # Default output format for the CLI "default_format": "html", # Maximum README length (in characters) to include. 0 = no limit "max_readme_length": 0, # Whether to strip badge lines from the top of README files "strip_readme_badges": True, - # Patterns for files to exclude from LSP analysis + # Extra paths for the Language Server to skip when indexing the workspace "exclude_patterns": [], } +# Expected shape of each option, derived from the default it ships with so the +# two can't drift. ``list`` means "list of strings". An option defaulting to +# None would need stating explicitly here; none currently does. +_FIELD_TYPES: dict[str, type] = {key: type(value) for key, value in DEFAULT_CONFIG.items()} + +# Options that don't affect extraction, and so stay out of the cache key. +_CLI_ONLY_FIELDS = frozenset({"default_format"}) + +# Options whose value has to be one of a known set, beyond just having the right +# type, paired with what to tell the user when it isn't. Checked in from_dict so +# every option is validated in one place. +_FIELD_VALUE_CHECKS: dict[str, tuple[Callable[[Any], bool], str]] = { + "default_format": (is_supported_format, f"one of {', '.join(supported_formats())}"), +} + + +def _requirement(key: str) -> str: + """Describe what an option accepts, completing "should be ..." in a warning.""" + if key in _FIELD_VALUE_CHECKS: + return _FIELD_VALUE_CHECKS[key][1] + return "a list of strings" if _FIELD_TYPES[key] is list else _FIELD_TYPES[key].__name__ + + +def _is_valid(key: str, value: Any) -> bool: + """ + Check a config value against the type, and domain, its option expects. + + Every option is consulted during extraction or by the CLI, so a bad value + would otherwise reach real code and fail there instead of at the boundary. + """ + expected = _FIELD_TYPES[key] + if expected is list: + if not (isinstance(value, list) and all(isinstance(item, str) for item in value)): + return False + elif expected is int and isinstance(value, bool): + return False # bool subclasses int, but `max_readme_length: true` is a mistake + elif not isinstance(value, expected): + return False + check = _FIELD_VALUE_CHECKS.get(key) + return check[0](value) if check else True + def get_xdg_config_home() -> Path: """Get the XDG config directory.""" @@ -65,19 +109,23 @@ class NfDocsConfig: Parameters starting with any of these prefixes will be excluded from the Parameters section. Default: []. include_hidden_params: Whether to include parameters marked as hidden - in the nextflow_schema.json. Default: False. - default_format: Default output format when not specified. Default: "html". - max_readme_length: Maximum README content length to include (0 = no limit). - Default: 0. + in the nextflow_schema.json. Default: True. + default_format: Default output format for the ``nf-docs generate`` command + when ``-f/--format`` is not given. Only the CLI reads this; the Python + API takes its format as an argument. Default: "html". + max_readme_length: Maximum length of README *source text* to include, in + characters (0 = no limit). Local images are embedded as base64 after + the cut, so the stored content can be larger than this. Default: 0. strip_readme_badges: Whether to strip badge lines (images/links at the top) from README files. Default: True. - exclude_patterns: Glob patterns for files to exclude from LSP analysis. - Default: []. + exclude_patterns: Extra paths for the Language Server to skip when indexing + the workspace, added to the built-in exclusions rather than replacing + them. Default: []. """ ignore_config_prefixes: list[str] = field(default_factory=lambda: ["genomes."]) ignore_input_prefixes: list[str] = field(default_factory=list) - include_hidden_params: bool = False + include_hidden_params: bool = True default_format: str = "html" max_readme_length: int = 0 strip_readme_badges: bool = True @@ -99,38 +147,53 @@ def should_ignore_input_param(self, param_name: str) -> bool: @classmethod def from_dict(cls, data: dict[str, Any]) -> "NfDocsConfig": - """Create a config from a dictionary, using defaults for missing values.""" - return cls( - ignore_config_prefixes=data.get( - "ignore_config_prefixes", DEFAULT_CONFIG["ignore_config_prefixes"] - ), - ignore_input_prefixes=data.get( - "ignore_input_prefixes", DEFAULT_CONFIG["ignore_input_prefixes"] - ), - include_hidden_params=data.get( - "include_hidden_params", DEFAULT_CONFIG["include_hidden_params"] - ), - default_format=data.get("default_format", DEFAULT_CONFIG["default_format"]), - max_readme_length=data.get("max_readme_length", DEFAULT_CONFIG["max_readme_length"]), - strip_readme_badges=data.get( - "strip_readme_badges", DEFAULT_CONFIG["strip_readme_badges"] - ), - exclude_patterns=data.get("exclude_patterns", DEFAULT_CONFIG["exclude_patterns"]), - ) + """ + Create a config from a dictionary, using defaults for missing values. + + A value of the wrong type falls back to the default with a warning, so a + typo in ``config.yaml`` can't surface as a traceback from the middle of + extraction. Unknown keys are ignored. + + Args: + data: Parsed contents of a config file + + Returns: + The configuration to use. + """ + values: dict[str, Any] = {} + for key, default in DEFAULT_CONFIG.items(): + if key in data: + if _is_valid(key, data[key]): + values[key] = data[key] + continue + logger.warning( + f"Config option {key!r} should be {_requirement(key)}, " + f"got {data[key]!r}. Using the default: {default!r}" + ) + # Copy, so the caller can't mutate DEFAULT_CONFIG through the result. + values[key] = list(default) if isinstance(default, list) else default + return cls(**values) def cache_key(self) -> str: """ - Stable short hash of this configuration. + Stable short hash of the options that shape extraction. Extraction results depend on the config in use, so the cache key has to include it. Without this, a CLI run (which loads the user's config file) and a library call (which uses defaults) would share a cache entry on the same unchanged pipeline and return each other's results. + ``default_format`` is left out: it only picks the CLI's output format, + so including it would evict every cached extraction - each one a full + Language Server run - to change a presentation default. + Returns: A hex digest suitable for use in a cache filename. """ - payload = json.dumps(self.to_dict(), sort_keys=True) + extraction_options = { + key: value for key, value in self.to_dict().items() if key not in _CLI_ONLY_FIELDS + } + payload = json.dumps(extraction_options, sort_keys=True) return hashlib.sha256(payload.encode()).hexdigest()[:16] def to_dict(self) -> dict[str, Any]: @@ -217,15 +280,18 @@ def get_example_config() -> str: # Whether to include parameters marked as "hidden" in nextflow_schema.json. # Hidden parameters are typically advanced options not needed by most users. -# Default: false -include_hidden_params: false +# Set to false to leave them out of the documentation entirely. +# Default: true +include_hidden_params: true -# Default output format when -f/--format is not specified. -# Options: html, json, yaml, markdown, md +# Default output format for `nf-docs generate` when -f/--format is not specified. +# Options: html, json, yaml, markdown (or md), table # Default: html default_format: html -# Maximum length of README content to include (in characters). +# Maximum length of README source text to include, in characters. +# The cut happens before local images are embedded as base64, so this limits +# how much of the README is kept, not the size of the stored result. # Set to 0 for no limit. # Default: 0 max_readme_length: 0 @@ -236,10 +302,14 @@ def get_example_config() -> str: # Default: true strip_readme_badges: true -# Glob patterns for files to exclude from LSP analysis. -# Useful for excluding test files, examples, etc. -# Default: [] -exclude_patterns: [] -# - "tests/**/*.nf" -# - "examples/**/*.nf" +# Extra paths for the Nextflow Language Server to skip when indexing. +# Useful for excluding test data, examples, etc. These are added to the paths +# nf-docs always excludes (.git, .nf-test, work) rather than replacing them. +# Entries are passed through to the language server's `nextflow.files.exclude` +# setting, so directory names are the safest thing to put here. +# Uncomment and edit to set some. Default: none. +# +# exclude_patterns: +# - "tests" +# - "examples" """ diff --git a/src/nf_docs/extractor.py b/src/nf_docs/extractor.py index d6bc1aa..6f032b5 100644 --- a/src/nf_docs/extractor.py +++ b/src/nf_docs/extractor.py @@ -30,6 +30,7 @@ Function, FunctionParam, Pipeline, + PipelineInput, PipelineMetadata, Process, ProcessInput, @@ -245,6 +246,8 @@ def extract(self) -> Pipeline: ) return pipeline + schema_param_names: set[str] = set() + # Extract from schema (has highest priority for inputs and metadata) schema_file = find_schema_file(self.workspace_path) if schema_file: @@ -259,7 +262,8 @@ def extract(self) -> Pipeline: try: schema_metadata, schema_inputs = parse_schema(schema_file) pipeline.metadata = schema_metadata - pipeline.inputs = schema_inputs + schema_param_names = {inp.name for inp in schema_inputs} + pipeline.inputs = self._filter_inputs(schema_inputs) except Exception as e: logger.warning(f"Failed to parse schema: {e}") @@ -274,12 +278,15 @@ def extract(self) -> Pipeline: config_metadata, config_params = parse_config(self.workspace_path, self.nextflow_path) # Merge metadata (schema takes priority) pipeline.metadata = self._merge_metadata(pipeline.metadata, config_metadata) - # Filter config params to exclude those already in inputs and ignored prefixes - input_names = {inp.name for inp in pipeline.inputs} + # Drop config params matching an ignore prefix, and any that name a + # schema parameter — including one ``_filter_inputs`` dropped, so a + # parameter the user asked to hide doesn't reappear under a + # different heading. pipeline.config_params = [ p for p in config_params - if p.name not in input_names and not self.config.should_ignore_config_param(p.name) + if p.name not in schema_param_names + and not self.config.should_ignore_config_param(p.name) ] except Exception as e: logger.warning(f"Failed to parse config: {e}") @@ -333,6 +340,31 @@ def extract(self) -> Pipeline: return pipeline + def _filter_inputs(self, inputs: list[PipelineInput]) -> list[PipelineInput]: + """ + Drop schema inputs the configuration asks us to leave out. + + Removes parameters marked ``hidden`` in the schema unless + ``include_hidden_params`` is set, and any parameter matching + ``ignore_input_prefixes``. + + Args: + inputs: Parameters as parsed from ``nextflow_schema.json`` + + Returns: + The parameters that should appear in the documentation + """ + kept = [ + inp + for inp in inputs + if (self.config.include_hidden_params or not inp.hidden) + and not self.config.should_ignore_input_param(inp.name) + ] + dropped = len(inputs) - len(kept) + if dropped: + logger.debug(f"Filtered out {dropped} of {len(inputs)} schema inputs") + return kept + def _merge_metadata( self, primary: PipelineMetadata, secondary: PipelineMetadata ) -> PipelineMetadata: @@ -351,7 +383,8 @@ def _extract_readme_content(self) -> str: """ Extract full README content after the first h1 heading. - Also converts local images to base64 data URIs for portability. + Also truncates to ``max_readme_length`` and converts local images to + base64 data URIs for portability. """ readme_candidates = [ self.workspace_path / "README.md", @@ -364,6 +397,9 @@ def _extract_readme_content(self) -> str: try: content = readme_path.read_text(encoding="utf-8") parsed_content = self._parse_readme_content(content) + # Truncate before embedding images, so the limit applies to + # the prose rather than to base64 data URIs + parsed_content = self._truncate_readme_content(parsed_content) # Convert local images to base64 return self._convert_images_to_base64(parsed_content, readme_path.parent) except Exception as e: @@ -371,17 +407,41 @@ def _extract_readme_content(self) -> str: return "" + def _truncate_readme_content(self, content: str) -> str: + """ + Trim README content to ``max_readme_length`` characters. + + Cuts at the last line break at or before the limit so that markdown + constructs aren't severed mid-line, falling back to a hard cut for + content with no line break in range. A limit of 0 means no limit. + + Args: + content: Parsed README content + + Returns: + The content, truncated if it exceeded the configured limit + """ + limit = self.config.max_readme_length + if limit <= 0 or len(content) <= limit: + return content + + cut = content.rfind("\n", 0, limit + 1) + truncated = content[:cut] if cut > 0 else content[:limit] + logger.debug(f"Truncated README from {len(content)} to {len(truncated)} characters") + return truncated + def _parse_readme_content(self, content: str) -> str: """ Parse README content, returning everything after the first h1 heading. - Strips the title (markdown # or HTML

) and any badge lines immediately following. + Strips the title (markdown # or HTML

) and, when + ``strip_readme_badges`` is set, any badge lines immediately following. """ lines = content.split("\n") result_lines: list[str] = [] found_title = False in_html_h1 = False - skip_badges = True + skip_badges = self.config.strip_readme_badges for line in lines: stripped = line.strip() @@ -410,7 +470,7 @@ def _parse_readme_content(self, content: str) -> str: # Still looking for title continue - # Skip badge lines immediately after title + # Skip badge lines immediately after title (if configured) if skip_badges: # Badge patterns: [![...], ![...], [!..., or lines containing "badge" if ( @@ -531,6 +591,7 @@ def _extract_from_lsp(self, pipeline: Pipeline, git_info: GitInfo | None = None) lsp_root, server_jar=self.language_server_jar, progress_callback=self._progress, + exclude_patterns=self.config.exclude_patterns, ) as client: # Try workspace symbols first to see what the LSP knows about workspace_symbols = client.get_workspace_symbols("") diff --git a/src/nf_docs/lsp_client.py b/src/nf_docs/lsp_client.py index 4f4d31b..29ce856 100644 --- a/src/nf_docs/lsp_client.py +++ b/src/nf_docs/lsp_client.py @@ -47,6 +47,28 @@ def get_xdg_data_home() -> Path: LANGUAGE_SERVER_REPO = "nextflow-io/language-server" LANGUAGE_SERVER_JAR = "language-server-all.jar" +# Paths always excluded from the language server's workspace scan. This list +# must stay non-empty: the server only initialises a workspace when the +# configuration it receives differs from its defaults (see initialize()). +DEFAULT_LSP_EXCLUDES = [".git", ".nf-test", "work"] + + +def build_exclude_list(exclude_patterns: list[str] | None = None) -> list[str]: + """ + Combine the built-in exclusions with any the user configured. + + User patterns are appended rather than substituted, so that configuring + them never removes the built-in exclusions or empties the list. + + Args: + exclude_patterns: Extra paths/patterns from ``NfDocsConfig.exclude_patterns`` + + Returns: + The exclusions to send to the language server, de-duplicated and in order + """ + merged = dict.fromkeys([*DEFAULT_LSP_EXCLUDES, *(exclude_patterns or [])]) + return [entry for entry in merged if entry] + class LSPError(Exception): """Exception raised for LSP-related errors.""" @@ -69,6 +91,7 @@ def __init__( java_path: str = "java", auto_download: bool = True, progress_callback: ProgressCallbackType | None = None, + exclude_patterns: list[str] | None = None, ): """ Initialize the LSP client. @@ -79,10 +102,13 @@ def __init__( java_path: Path to Java executable auto_download: Whether to auto-download the language server if not found progress_callback: Optional callback for progress updates + exclude_patterns: Extra paths to exclude from the workspace scan, added + to :data:`DEFAULT_LSP_EXCLUDES` """ self.workspace_path = Path(workspace_path).resolve() self.java_path = java_path self.auto_download = auto_download + self.file_excludes = build_exclude_list(exclude_patterns) self._progress = progress_callback or null_progress self._process: subprocess.Popen | None = None self._request_id = 0 @@ -467,15 +493,13 @@ def _initialize(self) -> None: # Send configuration to trigger workspace initialization. # The LSP only initializes workspaces when didChangeConfiguration is received # AND the configuration differs from defaults in specific fields. - # We set excludePatterns to a non-empty list to trigger initialization. + # The exclude list is always non-empty, which is what triggers initialization. self._send_notification( "workspace/didChangeConfiguration", { "settings": { "nextflow": { - "files": { - "exclude": [".git", ".nf-test", "work"] # Non-empty to trigger init - }, + "files": {"exclude": self.file_excludes}, "formatting": {"harshilAlignment": False}, "java": {"home": ""}, "suppressFutureWarnings": False, diff --git a/src/nf_docs/output.py b/src/nf_docs/output.py index e5946b6..8a0e823 100644 --- a/src/nf_docs/output.py +++ b/src/nf_docs/output.py @@ -56,6 +56,34 @@ def normalize_format(output_format: str) -> str: return FORMAT_ALIASES.get(lowered, lowered) +def is_supported_format(output_format: str) -> bool: + """ + Check whether a format name (or alias) is one nf-docs can render. + + This module is a leaf - it imports nothing from the rest of the package - + so ``config.py`` can validate a config file's ``default_format`` without + pulling in the renderers. ``tests/test_renderers.py`` pins these names to + the renderer registry. + + Args: + output_format: Format name as the user wrote it + + Returns: + True if the format can be rendered. + """ + return normalize_format(output_format) in SINGLE_FILE_OUTPUT_POLICY + + +def supported_formats() -> list[str]: + """ + Every format name accepted on input, canonical names and aliases, sorted. + + Returns: + Names suitable for a ``click.Choice`` or a help string. + """ + return sorted({*SINGLE_FILE_OUTPUT_POLICY, *FORMAT_ALIASES}) + + def format_extension(output_format: str) -> str: """ Get the file extension for a canonical format name. diff --git a/src/nf_docs/renderers/__init__.py b/src/nf_docs/renderers/__init__.py index 95a7216..5c77158 100644 --- a/src/nf_docs/renderers/__init__.py +++ b/src/nf_docs/renderers/__init__.py @@ -9,7 +9,7 @@ Table: Compact Markdown tables (terraform-docs style) """ -from nf_docs.output import FORMAT_ALIASES, normalize_format +from nf_docs.output import normalize_format, supported_formats from nf_docs.renderers.base import BaseRenderer from nf_docs.renderers.html import HTMLRenderer from nf_docs.renderers.json import JSONRenderer @@ -55,7 +55,7 @@ def get_renderer(format: str) -> type[BaseRenderer]: """ canonical = normalize_format(format) if canonical not in RENDERERS: - supported = ", ".join(sorted({*RENDERERS, *FORMAT_ALIASES})) + supported = ", ".join(supported_formats()) raise ValueError(f"Unsupported format: {format}. Supported formats: {supported}") return RENDERERS[canonical] diff --git a/tests/conftest.py b/tests/conftest.py index de1afd0..ddb04c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,8 @@ def isolate_xdg_dirs(tmp_path_factory, monkeypatch) -> None: Without this, any test that extracts with caching left entries in the developer's real ``~/.cache/nf-docs/`` - and because the Language Server is mocked out in tests, those entries are empty results that a later real run - could pick up. + could pick up. It also keeps anything reaching ``load_config()`` independent + of whether the person running the suite has a real config file. """ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path_factory.mktemp("xdg_cache"))) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path_factory.mktemp("xdg_config"))) diff --git a/tests/test_cli.py b/tests/test_cli.py index c0eeff9..0e7352c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,12 +1,14 @@ """Tests for the CLI interface.""" +import logging from pathlib import Path from unittest.mock import patch import pytest from click.testing import CliRunner -from nf_docs.cli import generate, inspect, main +from nf_docs.cli import config, generate, inspect, main +from nf_docs.config import get_config_path from nf_docs.extractor import PipelineExtractor @@ -16,6 +18,14 @@ def runner(): return CliRunner() +def write_user_config(body: str) -> Path: + """Write a config file at the (isolated) XDG config path and return its path.""" + config_file = get_config_path() + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(body, encoding="utf-8") + return config_file + + class TestMainCommand: def test_help(self, runner: CliRunner): result = runner.invoke(main, ["--help"]) @@ -265,3 +275,107 @@ def test_inspect_verbose(self, runner: CliRunner, sample_pipeline: Path): with patch.object(PipelineExtractor, "_extract_from_lsp"): result = runner.invoke(inspect, [str(sample_pipeline), "--verbose"]) assert result.exit_code == 0 + + +class TestDefaultFormatConfig: + """ + The config file's default_format is what `generate` uses without -f. + + ``isolate_xdg_dirs`` (conftest) points XDG_CONFIG_HOME at a scratch + directory, so these write a real config file and exercise ``load_config()``. + """ + + def test_default_format_from_config_is_used(self, runner: CliRunner, sample_pipeline: Path): + """With no -f, the configured format wins over the built-in html.""" + write_user_config("default_format: json\n") + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline)]) + + assert result.exit_code == 0 + assert '"pipeline"' in result.output + + def test_explicit_format_flag_wins(self, runner: CliRunner, sample_pipeline: Path): + """An explicit -f overrides the configured default.""" + write_user_config("default_format: json\n") + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline), "--format", "yaml"]) + + assert result.exit_code == 0 + assert "pipeline:" in result.output + + def test_no_config_file_still_defaults_to_html( + self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path + ): + """Without a config file the default is unchanged.""" + output = tmp_path / "site" + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline), "-o", str(output)]) + + assert result.exit_code == 0 + assert (output / "index.html").exists() + + def test_unknown_default_format_warns_and_falls_back( + self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path, caplog + ): + """A bad config value is a warning and html, not a traceback.""" + write_user_config("default_format: nonsense\n") + output = tmp_path / "site" + + with caplog.at_level(logging.WARNING), patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline), "-o", str(output)]) + + assert result.exit_code == 0 + assert "nonsense" in caplog.text + assert (output / "index.html").exists() + + def test_config_command_reports_the_format_actually_used(self, runner: CliRunner): + """ + `nf-docs config` must not report a value `generate` would reject. + + Validating default_format at load time rather than in the CLI is what + keeps these two commands agreeing. + """ + write_user_config("default_format: nonsense\n") + + result = runner.invoke(config, []) + + assert result.exit_code == 0 + assert "default_format: html" in result.output + assert "nonsense" not in result.output + + +class TestConfigCommand: + def test_shows_current_settings(self, runner: CliRunner): + result = runner.invoke(config, []) + assert result.exit_code == 0 + assert "ignore_config_prefixes" in result.output + + def test_path(self, runner: CliRunner): + result = runner.invoke(config, ["--path"]) + assert result.exit_code == 0 + assert "config.yaml" in result.output + + def test_show_example(self, runner: CliRunner): + result = runner.invoke(config, ["--show-example"]) + assert result.exit_code == 0 + assert "ignore_config_prefixes:" in result.output + + def test_init_creates_file(self, runner: CliRunner): + result = runner.invoke(config, ["--init"]) + assert result.exit_code == 0 + + created = get_config_path() + assert created.exists() + assert "ignore_config_prefixes:" in created.read_text() + + def test_init_does_not_overwrite(self, runner: CliRunner): + existing = write_user_config("default_format: json\n") + + result = runner.invoke(config, ["--init"]) + + assert result.exit_code == 0 + assert "already exists" in result.output + assert existing.read_text() == "default_format: json\n" diff --git a/tests/test_config.py b/tests/test_config.py index 126ac95..4219350 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,16 @@ """Tests for the configuration system.""" +import logging +from itertools import groupby from pathlib import Path import pytest +import yaml from nf_docs.config import ( DEFAULT_CONFIG, NfDocsConfig, + _is_valid, get_config_path, get_example_config, get_xdg_config_home, @@ -49,7 +53,7 @@ def test_default_values(self) -> None: config = NfDocsConfig() assert config.ignore_config_prefixes == ["genomes."] assert config.ignore_input_prefixes == [] - assert config.include_hidden_params is False + assert config.include_hidden_params is True assert config.default_format == "html" assert config.max_readme_length == 0 assert config.strip_readme_badges is True @@ -86,7 +90,7 @@ def test_from_dict_full(self) -> None: data = { "ignore_config_prefixes": ["custom."], "ignore_input_prefixes": ["private."], - "include_hidden_params": True, + "include_hidden_params": False, "default_format": "json", "max_readme_length": 5000, "strip_readme_badges": False, @@ -96,7 +100,7 @@ def test_from_dict_full(self) -> None: assert config.ignore_config_prefixes == ["custom."] assert config.ignore_input_prefixes == ["private."] - assert config.include_hidden_params is True + assert config.include_hidden_params is False assert config.default_format == "json" assert config.max_readme_length == 5000 assert config.strip_readme_badges is False @@ -105,13 +109,13 @@ def test_from_dict_full(self) -> None: def test_from_dict_partial(self) -> None: """Test from_dict with only some values specified.""" data = { - "include_hidden_params": True, + "include_hidden_params": False, "default_format": "yaml", } config = NfDocsConfig.from_dict(data) # Specified values - assert config.include_hidden_params is True + assert config.include_hidden_params is False assert config.default_format == "yaml" # Default values @@ -125,17 +129,81 @@ def test_from_dict_empty(self) -> None: assert config.include_hidden_params == DEFAULT_CONFIG["include_hidden_params"] assert config.default_format == DEFAULT_CONFIG["default_format"] + def test_defaults_are_themselves_valid(self) -> None: + """Each default passes the check applied to a user's value.""" + for key, default in DEFAULT_CONFIG.items(): + assert _is_valid(key, default), key + + def test_from_dict_ignores_unknown_keys(self) -> None: + """A key nf-docs doesn't know about is skipped, not an error.""" + config = NfDocsConfig.from_dict({"made_up_option": 1, "default_format": "json"}) + + assert config == NfDocsConfig(default_format="json") + + def test_from_dict_copies_list_defaults(self) -> None: + """Defaults are copied, so a caller can't mutate DEFAULT_CONFIG through them.""" + config = NfDocsConfig.from_dict({}) + config.ignore_config_prefixes.append("mutated.") + + assert NfDocsConfig.from_dict({}).ignore_config_prefixes == ["genomes."] + + @pytest.mark.parametrize( + ("key", "bad_value"), + [ + # Every one of these used to reach real code and fail there: a bare + # string silently iterated as characters, a string where an int was + # expected raised TypeError mid-extraction. + ("exclude_patterns", "tests"), + ("ignore_config_prefixes", "genomes."), + ("ignore_input_prefixes", [1, 2]), + ("max_readme_length", "lots"), + ("max_readme_length", True), + ("default_format", 3), + ("include_hidden_params", "yes"), + ("strip_readme_badges", 1), + ], + ) + def test_from_dict_falls_back_on_bad_value(self, key: str, bad_value: object, caplog) -> None: + """A value nf-docs can't use warns and falls back to the default.""" + with caplog.at_level(logging.WARNING): + config = NfDocsConfig.from_dict({key: bad_value}) + + assert getattr(config, key) == DEFAULT_CONFIG[key] + assert key in caplog.text + + def test_cache_key_ignores_default_format(self) -> None: + """default_format is CLI-only, so changing it must not evict extractions.""" + assert ( + NfDocsConfig(default_format="html").cache_key() + == NfDocsConfig(default_format="json").cache_key() + ) + + def test_cache_key_covers_every_extraction_option(self) -> None: + """Any option that shapes extraction changes the key.""" + baseline = NfDocsConfig().cache_key() + variants = [ + NfDocsConfig(ignore_config_prefixes=[]), + NfDocsConfig(ignore_input_prefixes=["x."]), + NfDocsConfig(include_hidden_params=False), + NfDocsConfig(max_readme_length=500), + NfDocsConfig(strip_readme_badges=False), + NfDocsConfig(exclude_patterns=["tests"]), + ] + + for variant in variants: + assert variant.cache_key() != baseline, variant + def test_to_dict(self) -> None: """Test to_dict conversion.""" config = NfDocsConfig( ignore_config_prefixes=["foo."], - include_hidden_params=True, + include_hidden_params=False, default_format="markdown", ) result = config.to_dict() assert result["ignore_config_prefixes"] == ["foo."] - assert result["include_hidden_params"] is True + assert result["include_hidden_params"] is False assert result["default_format"] == "markdown" assert "ignore_input_prefixes" in result assert "max_readme_length" in result @@ -151,7 +219,7 @@ def test_load_default_when_file_missing(self, tmp_path: Path) -> None: config = load_config(tmp_path / "nonexistent" / "config.yaml") assert config.ignore_config_prefixes == ["genomes."] - assert config.include_hidden_params is False + assert config.include_hidden_params is True @pytest.mark.parametrize( "contents", @@ -175,7 +243,7 @@ def test_load_from_file(self, tmp_path: Path) -> None: ignore_config_prefixes: - "custom." - "other." -include_hidden_params: true +include_hidden_params: false default_format: json """ ) @@ -183,7 +251,7 @@ def test_load_from_file(self, tmp_path: Path) -> None: config = load_config(config_file) assert config.ignore_config_prefixes == ["custom.", "other."] - assert config.include_hidden_params is True + assert config.include_hidden_params is False assert config.default_format == "json" # Non-specified values should use defaults assert config.strip_readme_badges is True @@ -197,7 +265,7 @@ def test_load_empty_file(self, tmp_path: Path) -> None: # Should use all defaults assert config.ignore_config_prefixes == ["genomes."] - assert config.include_hidden_params is False + assert config.include_hidden_params is True def test_load_invalid_yaml(self, tmp_path: Path) -> None: """Test that invalid YAML returns defaults with warning.""" @@ -208,7 +276,7 @@ def test_load_invalid_yaml(self, tmp_path: Path) -> None: # Should use defaults on error assert config.ignore_config_prefixes == ["genomes."] - assert config.include_hidden_params is False + assert config.include_hidden_params is True class TestGetExampleConfig: @@ -216,8 +284,6 @@ class TestGetExampleConfig: def test_returns_valid_yaml(self) -> None: """Test that example config is valid YAML.""" - import yaml - example = get_example_config() data = yaml.safe_load(example) @@ -226,6 +292,33 @@ def test_returns_valid_yaml(self) -> None: assert "include_hidden_params" in data assert "default_format" in data + def test_describes_the_actual_defaults(self) -> None: + """ + `nf-docs config --init` writes this file verbatim, so a value that has + drifted from the code silently changes behaviour for anyone who runs it. + """ + assert NfDocsConfig.from_dict(yaml.safe_load(get_example_config())) == NfDocsConfig() + + def test_example_config_suggestions_uncomment_to_valid_yaml(self) -> None: + """ + Uncommenting a suggested value must leave a usable config file. + + A suggestion that uncomments to a bare list is an orphan under whatever + precedes it, which makes the whole file unparseable - and a broken file + isn't a partial failure. load_config() discards all of it and silently + reverts every option to its default. + """ + lines = get_example_config().splitlines() + for is_comment, group in groupby(lines, lambda line: line.startswith("#")): + if not is_comment: + continue + block = "\n".join(line.removeprefix("#").removeprefix(" ") for line in group) + try: + uncommented = yaml.safe_load(block) + except yaml.YAMLError: + continue # Prose, not a suggested setting + assert not isinstance(uncommented, list), block + def test_contains_comments(self) -> None: """Test that example config contains helpful comments.""" example = get_example_config() diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 2ea71c2..e98e253 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -1,10 +1,13 @@ """Tests for the pipeline extractor.""" +import json from pathlib import Path from unittest.mock import patch from nf_docs.cache import PipelineCache +from nf_docs.config import NfDocsConfig from nf_docs.extractor import PipelineExtractor, find_pipeline_root +from nf_docs.models import ConfigParam, Pipeline, PipelineMetadata class TestPipelineExtractor: @@ -228,6 +231,152 @@ def test_inputs_grouped_correctly(self, sample_pipeline: Path): assert "Reference genome options" in groups +class TestConfigOptions: + """ + Each NfDocsConfig option changes what comes out of extraction. + + These assert both settings of every option, so they fail whether an option + stops being consulted or starts being applied backwards. + """ + + def _write_schema(self, workspace: Path) -> None: + """Write a schema with one hidden parameter and one prefixed parameter.""" + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test Pipeline", + "$defs": { + "options": { + "title": "Options", + "type": "object", + "properties": { + "input": {"type": "string", "description": "Samplesheet"}, + "tracedir": { + "type": "string", + "description": "Trace directory", + "hidden": True, + }, + "internal_debug": {"type": "boolean", "description": "Debug"}, + }, + } + }, + } + (workspace / "nextflow_schema.json").write_text(json.dumps(schema)) + + def _extract(self, workspace: Path, config: NfDocsConfig) -> Pipeline: + """Extract from ``workspace`` with the LSP and cache out of the way.""" + with patch.object(PipelineExtractor, "_extract_from_lsp"): + extractor = PipelineExtractor(workspace_path=workspace, config=config, use_cache=False) + return extractor.extract() + + def test_include_hidden_params_keeps_hidden_by_default(self, tmp_path: Path): + """Hidden schema parameters are included unless the option says otherwise.""" + self._write_schema(tmp_path) + + pipeline = self._extract(tmp_path, NfDocsConfig()) + + assert "tracedir" in {inp.name for inp in pipeline.inputs} + + def test_include_hidden_params_false_drops_hidden(self, tmp_path: Path): + """Setting include_hidden_params to False removes hidden parameters.""" + self._write_schema(tmp_path) + + pipeline = self._extract(tmp_path, NfDocsConfig(include_hidden_params=False)) + + input_names = {inp.name for inp in pipeline.inputs} + assert "tracedir" not in input_names + # Visible parameters are untouched + assert "input" in input_names + + def test_ignore_input_prefixes_drops_matching_params(self, tmp_path: Path): + """Inputs matching ignore_input_prefixes are excluded.""" + self._write_schema(tmp_path) + + kept = self._extract(tmp_path, NfDocsConfig()) + assert "internal_debug" in {inp.name for inp in kept.inputs} + + dropped = self._extract(tmp_path, NfDocsConfig(ignore_input_prefixes=["internal_"])) + input_names = {inp.name for inp in dropped.inputs} + assert "internal_debug" not in input_names + assert "input" in input_names + + def test_filtered_inputs_do_not_reappear_as_config_params(self, tmp_path: Path): + """A parameter hidden from the inputs section isn't shown as a config param.""" + self._write_schema(tmp_path) + config_params = [ConfigParam(name="tracedir", default="./trace")] + + with patch( + "nf_docs.extractor.parse_config", return_value=(PipelineMetadata(), config_params) + ): + pipeline = self._extract(tmp_path, NfDocsConfig(include_hidden_params=False)) + + assert "tracedir" not in {p.name for p in pipeline.config_params} + + def test_ignore_config_prefixes_drops_matching_params(self, tmp_path: Path): + """Config params matching ignore_config_prefixes are excluded.""" + config_params = [ + ConfigParam(name="genomes.GRCh38.fasta", default="ref.fa"), + ConfigParam(name="max_cpus", default=16), + ] + + with patch( + "nf_docs.extractor.parse_config", return_value=(PipelineMetadata(), config_params) + ): + kept = self._extract(tmp_path, NfDocsConfig(ignore_config_prefixes=[])) + dropped = self._extract(tmp_path, NfDocsConfig(ignore_config_prefixes=["genomes."])) + + assert "genomes.GRCh38.fasta" in {p.name for p in kept.config_params} + dropped_names = {p.name for p in dropped.config_params} + assert "genomes.GRCh38.fasta" not in dropped_names + assert "max_cpus" in dropped_names + + def test_strip_readme_badges_false_keeps_badges(self, tmp_path: Path): + """Badge lines survive when strip_readme_badges is turned off.""" + (tmp_path / "README.md").write_text( + "# My Pipeline\n\n" + "[![Build Status](https://example.com/badge.svg)](https://example.com)\n\n" + "This is the actual description.\n" + ) + + stripped = self._extract(tmp_path, NfDocsConfig()) + kept = self._extract(tmp_path, NfDocsConfig(strip_readme_badges=False)) + + assert "badge" not in stripped.metadata.readme_content.lower() + assert "badge" in kept.metadata.readme_content.lower() + # Either way the prose is preserved - stripping must not eat the body + assert "actual description" in stripped.metadata.readme_content.lower() + assert "actual description" in kept.metadata.readme_content.lower() + + def test_max_readme_length_truncates_at_a_line_break(self, tmp_path: Path): + """README content is trimmed to the configured length, on a line boundary.""" + body = "\n".join(f"Line {i} of the readme." for i in range(50)) + (tmp_path / "README.md").write_text(f"# My Pipeline\n\n{body}\n") + + full = self._extract(tmp_path, NfDocsConfig()).metadata.readme_content + truncated = self._extract(tmp_path, NfDocsConfig(max_readme_length=100)) + content = truncated.metadata.readme_content + + assert len(full) > 100 + assert len(content) <= 100 + assert full.startswith(content) + # Cut on a line boundary, so no line is left half-written + assert full[len(content)] == "\n" + + def test_exclude_patterns_reach_the_language_server(self, tmp_path: Path): + """Configured exclude patterns are passed through to the LSP client.""" + (tmp_path / "main.nf").write_text("process FOO {}\n") + + with patch("nf_docs.extractor.LSPClient") as mock_lsp_cls: + mock_lsp_cls.return_value.__enter__.return_value.get_workspace_symbols.return_value = [] + extractor = PipelineExtractor( + workspace_path=tmp_path, + config=NfDocsConfig(exclude_patterns=["testdata"]), + use_cache=False, + ) + extractor.extract() + + assert mock_lsp_cls.call_args.kwargs["exclude_patterns"] == ["testdata"] + + class TestSymbolNameParsing: """Test parsing of Nextflow LSP symbol names.""" diff --git a/tests/test_lsp_client.py b/tests/test_lsp_client.py new file mode 100644 index 0000000..4ee6c51 --- /dev/null +++ b/tests/test_lsp_client.py @@ -0,0 +1,37 @@ +"""Tests for the Nextflow Language Server client.""" + +from nf_docs.lsp_client import DEFAULT_LSP_EXCLUDES, build_exclude_list + + +class TestBuildExcludeList: + """Tests for build_exclude_list.""" + + def test_defaults_when_nothing_configured(self): + """With no user patterns we send exactly the built-in exclusions.""" + assert build_exclude_list() == DEFAULT_LSP_EXCLUDES + assert build_exclude_list([]) == DEFAULT_LSP_EXCLUDES + + def test_user_patterns_are_appended(self): + """User patterns are added to the built-ins rather than replacing them.""" + result = build_exclude_list(["testdata", "examples"]) + + assert result[: len(DEFAULT_LSP_EXCLUDES)] == DEFAULT_LSP_EXCLUDES + assert result[len(DEFAULT_LSP_EXCLUDES) :] == ["testdata", "examples"] + + def test_duplicates_are_dropped(self): + """Repeating a built-in doesn't duplicate it.""" + result = build_exclude_list(["work", "testdata", "testdata"]) + + assert result.count("work") == 1 + assert result.count("testdata") == 1 + + def test_never_empty(self): + """ + The list must stay non-empty. + + The language server only initialises a workspace when the configuration + it receives differs from its defaults, so an empty exclude list means no + indexing at all. + """ + assert build_exclude_list([]) != [] + assert build_exclude_list([""]) != [] diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 57eb4a1..8ae6dff 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -15,7 +15,13 @@ ProcessOutput, Workflow, ) +from nf_docs.output import ( + FORMAT_ALIASES, + SINGLE_FILE_OUTPUT_POLICY, + supported_formats, +) from nf_docs.renderers import ( + RENDERERS, HTMLRenderer, JSONRenderer, MarkdownRenderer, @@ -753,3 +759,19 @@ def test_render_to_directory_empty_markers_renders_all( assert "## Inputs" in content assert "## Processes" in content assert "Footer." in content + + +class TestFormatRegistryConsistency: + def test_renderers_and_output_policy_cover_the_same_formats(self): + """ + Every renderable format needs an output policy, and vice versa. + + `output.py` is a leaf module, so `config.py` validates a config file's + default_format against SINGLE_FILE_OUTPUT_POLICY rather than importing + the renderers. That is only sound while the two agree. + """ + assert set(RENDERERS) == set(SINGLE_FILE_OUTPUT_POLICY) + + def test_supported_formats_covers_every_renderer_and_alias(self): + """The name list offered to users matches what get_renderer() accepts.""" + assert set(supported_formats()) == {*RENDERERS, *FORMAT_ALIASES}