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"
+ "[](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}