Source code for sphinx_fortran_domain.directives

from __future__ import annotations

import os
import re
from pathlib import Path

from docutils import nodes
from docutils.parsers.rst import Directive
from docutils.parsers.rst import directives as rst_directives
from docutils.statemachine import StringList
from sphinx import addnodes
from sphinx.directives import ObjectDescription
from sphinx.util import logging
from sphinx.util.parsing import nested_parse_to_nodes

from sphinx_fortran_domain.utils import (
	collect_fortran_source_files_from_config,
	doc_markers_from_doc_chars,
	extract_predoc_before_line,
	extract_use_dependencies,
	read_lines_utf8,
	read_text_utf8,
)


logger = logging.getLogger(__name__)

_RE_DOC_SECTION = re.compile(r"^\s*##\s+(?P<title>\S.*?\S)\s*$")
_RE_FOOTNOTE_DEF = re.compile(r"^\s*\.\.\s*\[(?P<label>\d+|#)\]\s+")
_RE_END_PROGRAM = re.compile(r"^\s*end\s*program\b", re.IGNORECASE)


def _doc_markers_from_env(env) -> list[str]:
	"""Return configured Fortran doc markers (e.g. ['!>'])."""
	app = getattr(env, "app", None)
	config = getattr(app, "config", None) if app is not None else None
	try:
		return doc_markers_from_doc_chars(getattr(config, "fortran_doc_chars", None))
	except Exception:
		# Keep directives resilient even if config is malformed.
		return ["!>"]


def _collect_fortran_files_from_env(env) -> list[str]:
	app = getattr(env, "app", None)
	if app is None:
		return []
	confdir = Path(getattr(app, "confdir", os.getcwd()))
	config = getattr(app, "config", None)
	return collect_fortran_source_files_from_config(confdir=confdir, config=config)


def _find_program_in_file(lines: list[str], progname: str, *, start_at: int = 0) -> int | None:
	pat = re.compile(rf"^\s*program\s+{re.escape(str(progname))}\b", re.IGNORECASE)
	for i in range(max(start_at, 0), len(lines)):
		if pat.match(lines[i]):
			return i
	return None


def _read_program_source_by_search(env, progname: str, *, doc_markers: list[str]) -> tuple[str | None, str | None]:
	"""Find and read a program unit by scanning configured fortran_sources.

	Returns (source, predoc) where predoc is doc lines immediately preceding
	the program statement (using configured doc_markers).
	"""
	files = _collect_fortran_files_from_env(env)
	if not files:
		return (None, None)

	for path in files:
		try:
			text = read_text_utf8(path)
		except OSError:
			continue
		lines = text.splitlines()
		start = _find_program_in_file(lines, progname)
		if start is None:
			continue

		predoc = extract_predoc_before_line(lines, start, doc_markers=doc_markers)
		buf: list[str] = []
		for i in range(start, len(lines)):
			buf.append(lines[i])
			if _RE_END_PROGRAM.match(lines[i]):
				break
		return ("\n".join(buf), predoc)

	return (None, None)


def _split_out_doc_section_blocks(text: str | None) -> tuple[str | None, str | None]:
	"""Split a docstring into (preamble, sections) based on our "## Title" markers.

	- preamble: everything before the first "##" section marker
	- sections: from the first "##" marker to the end

	This is used to control placement: the preamble stays near the top of object
	documentation, while section blocks (Notes/References/See Also/...) can be
	placed after intrinsic blocks (Arguments/Returns/Attributes/Procedures).
	"""
	if not text:
		return None, None

	lines = str(text).splitlines()
	first = None
	for i, line in enumerate(lines):
		if _RE_DOC_SECTION.match(line):
			first = i
			break

	if first is None:
		preamble = "\n".join(lines).strip() or None
		return preamble, None

	preamble = "\n".join(lines[:first]).strip() or None
	sections = "\n".join(lines[first:]).strip() or None
	return preamble, sections


def _preprocess_fortran_docstring(text: str) -> str:
	"""Normalize a lightweight docstring convention into valid reST.

	Supported patterns:
	- Section markers: "## References" -> ".. rubric:: References"
	- Example blocks: contiguous lines starting with ">>>" -> ".. code-block:: fortran"
	"""
	lines = (text or "").splitlines()
	out: list[str] = []
	i = 0
	while i < len(lines):
		line = lines[i]
		m = _RE_DOC_SECTION.match(line)
		if m:
			title = m.group("title")
			normalized = (title or "").strip().lower()

			# Match NumpyDoc-style rendering for "See Also" by emitting a real
			# Sphinx seealso directive (renders as an admonition with special styling).
			if normalized in {"see also", "seealso"}:
				if out and out[-1].strip() != "":
					out.append("")
				out.append(".. seealso::")
				out.append("")
				i += 1

				# Consume the See Also body until the next "##" section marker.
				# Support a lightweight "term : description" syntax (with spaces
				# around the colon) to produce a definition-list style layout.
				while i < len(lines) and not _RE_DOC_SECTION.match(lines[i]):
					body_line = lines[i]
					if not body_line.strip():
						out.append("")
						i += 1
						continue

					# Split only on " : " (spaces required) so domain roles like
					# ":f:func:`name`" are not misinterpreted.
					parts = re.split(r"\s+:\s+", body_line.strip(), maxsplit=1)
					if len(parts) == 2:
						term, desc = parts
						out.append("   " + term.strip())
						out.append("      " + desc.strip())
						out.append("")
					else:
						out.append("   " + body_line.strip())
						i += 1
						continue
					i += 1

				# Avoid accumulating extra blank lines at the end of the directive.
				while out and out[-1] == "":
					out.pop()
				out.append("")
				continue

			if out and out[-1].strip() != "":
				out.append("")
			out.append(f".. rubric:: {title}")
			out.append("")
			i += 1
			continue

		if line.lstrip().startswith(">>>"):
			block: list[str] = []
			while i < len(lines) and lines[i].lstrip().startswith(">>>"):
				s = lines[i].lstrip()[3:]
				block.append(s.lstrip(" \t"))
				i += 1

			if out and out[-1].strip() != "":
				out.append("")
			out.append(".. code-block:: fortran")
			out.append("")
			for b in block:
				out.append("   " + b)
			out.append("")
			continue
		
		stripped = line.lstrip()
		if stripped.startswith("```"):
			# Extract language if provided
			fence = stripped[3:].strip()   # after "```"
			language = fence if fence else "fortran"

			block: list[str] = []
			i += 1
			while i < len(lines):
				s = lines[i].lstrip()
				if s.startswith("```"):
					i += 1
					break
				block.append(s.rstrip("\t"))
				i += 1

			if out and out[-1].strip() != "":
				out.append("")
			out.append(f".. code-block:: {language}")
			out.append("")
			for b in block:
				out.append("   " + b)
			out.append("")
			continue

		out.append(line)
		i += 1

		# Make footnote/citation definitions robust in docstring fragments.
		# In reST, a footnote definition must be preceded by a blank line.
		# Many docstrings omit that blank line, which causes undefined refs like [1]_.
		# We insert it here for common `.. [n] ...` patterns.
		if _RE_FOOTNOTE_DEF.match(line):
			# If the previous non-empty line isn't blank, insert a blank line *before*
			# this definition. Because we've already appended the line, adjust in-place.
			if len(out) >= 2 and out[-2].strip() != "":
				out.insert(len(out) - 1, "")

	return "\n".join(out).rstrip()

def _make_object_id(objtype: str, fullname: str) -> str:
    # ``nodes.make_id`` produces a valid HTML id fragment.
    return nodes.make_id(f"f-{objtype}-{fullname}")


def _append_doc(section: nodes.Element, doc: str | None, state) -> None:
	if not doc:
		return

	text = _preprocess_fortran_docstring(str(doc))
	content = StringList(text.splitlines(), source="<fortran-doc>")
	container: nodes.Element = nodes.container()

	# Parse doc as a reST fragment so Sphinx roles/directives work (e.g. .. math::).
	for n in nested_parse_to_nodes(
		state,
		content,
		source="<fortran-doc>",
		offset=0,
		allow_section_headings=True,
		keep_title_context=True,
		):
		container += n

	section += container


def _read_program_source_from_location(location) -> str | None:
	"""Best-effort read of a program unit source from its file location."""
	if location is None:
		return None
	path = getattr(location, "path", None)
	lineno = getattr(location, "lineno", None)
	if not path or not lineno:
		return None
	try:
		lines = read_lines_utf8(path)
	except OSError:
		return None

	start = max(int(lineno) - 1, 0)
	buf: list[str] = []
	for i in range(start, len(lines)):
		buf.append(lines[i])
		if re.match(r"^\s*end\s*program\b", lines[i], flags=re.IGNORECASE):
			break
	return "\n".join(buf)

def _append_fortran_code_block(section: nodes.Element, *, source: str) -> None:
	text = (source or "").rstrip() + "\n"
	lit = nodes.literal_block(text, text)
	lit["language"] = "fortran"
	section += lit


def _append_program_dependencies(section: nodes.Element, *, dependencies, state) -> None:
	deps = [str(d).strip() for d in (dependencies or []) if str(d).strip()]
	if not deps:
		return
	items = nodes.bullet_list()
	for dep in deps:
		xref = addnodes.pending_xref(
			"",
			refdomain="f",
			reftype="module",
			reftarget=dep,
			refexplicit=True,
		)
		xref += nodes.literal(text=dep)
		items += nodes.list_item("", nodes.paragraph("", "", xref))

	section += nodes.subtitle(text="Dependencies")
	section += items


def _parse_doc_fragment(doc: str | None, state) -> list[nodes.Node]:
	if not doc:
		return []
	text = _preprocess_fortran_docstring(str(doc))
	content = StringList(text.splitlines(), source="<fortran-doc>")
	return list(
		nested_parse_to_nodes(
			state,
			content,
			source="<fortran-doc>",
			offset=0,
			allow_section_headings=True,
			keep_title_context=True,
		)
	)


def _field_list(title: str, body: nodes.Element) -> nodes.field_list:
	fl = nodes.field_list()
	field = nodes.field()
	field += nodes.field_name(text=title)
	fbody = nodes.field_body()
	fbody += body
	field += fbody
	fl += field
	return fl


def _stamp_source_line(node: nodes.Node, *, source: str = "<fortran>", line: int = 1) -> None:
	# Sphinx expects certain nodes (notably definition_list_item) to have
	# a non-None .line during HTML builds.
	if getattr(node, "source", None) is None:
		node.source = source  # type: ignore[attr-defined]
	if getattr(node, "line", None) is None:
		node.line = line  # type: ignore[attr-defined]

def _append_named_decl_docs(
	section: nodes.Element,
	title: str,
	items,
	state,
	*,
	anchors_by_name: dict[str, str] | None = None,
	as_field_list: bool = True,
) -> None:
	"""Render a standard name/decl/doc definition list inside a field list.

	This is the canonical rendering used for procedure Arguments and module Variables.
	"""
	if not items:
		return
	rows = []
	for a in items:
		name = getattr(a, "name", "")
		decl = getattr(a, "decl", None) or ""
		doc = getattr(a, "doc", None)
		if not name:
			continue
		rows.append((name, decl, doc))
	if not rows:
		return

	dl = nodes.definition_list()
	dl["classes"].append("simple")
	_stamp_source_line(dl)
	for name, decl, doc in rows:
		item = nodes.definition_list_item()
		_stamp_source_line(item)
		term = nodes.term()
		_stamp_source_line(term)
		anchor = (anchors_by_name or {}).get(str(name))
		if anchor:
			# Add an invisible target so xrefs land on the correct row without
			# changing the visual rendering.
			term += nodes.target(ids=[anchor])
		term += nodes.strong(text=str(name))
		item += term
		if decl:
			classifier = nodes.classifier(text=str(decl))
			_stamp_source_line(classifier)
			item += classifier

		definition = nodes.definition()
		_stamp_source_line(definition)
		for n in _parse_doc_fragment(doc, state):
			definition += n
		item += definition
		dl += item

	if as_field_list:
		section += _field_list(title, dl)
	else:
		section += nodes.subtitle(text=str(title))
		section += dl


def _append_return_docs(section: nodes.Element, result, state) -> None:
	if not result:
		return
	name = getattr(result, "name", "")
	decl = getattr(result, "decl", None) or ""
	doc = getattr(result, "doc", None)
	if not name:
		return

	dl = nodes.definition_list()
	dl["classes"].append("simple")
	_stamp_source_line(dl)

	item = nodes.definition_list_item()
	_stamp_source_line(item)
	term = nodes.term()
	_stamp_source_line(term)
	term += nodes.strong(text=str(name))
	item += term
	if decl:
		classifier = nodes.classifier(text=str(decl))
		_stamp_source_line(classifier)
		item += classifier

	definition = nodes.definition()
	_stamp_source_line(definition)
	for n in _parse_doc_fragment(doc, state):
		definition += n
	item += definition
	dl += item

	section += _field_list("Returns", dl)


def _append_component_docs(section: nodes.Element, components, state) -> None:
	if not components:
		return
	rows = []
	for c in components:
		name = getattr(c, "name", "")
		decl = getattr(c, "decl", None) or ""
		doc = getattr(c, "doc", None)
		if not name:
			continue
		rows.append((name, decl, doc))
	if not rows:
		return

	dl = nodes.definition_list()
	dl["classes"].append("simple")
	_stamp_source_line(dl)
	for name, decl, doc in rows:
		item = nodes.definition_list_item()
		_stamp_source_line(item)
		term = nodes.term()
		_stamp_source_line(term)
		term += nodes.strong(text=str(name))
		item += term
		if decl:
			classifier = nodes.classifier(text=str(decl))
			_stamp_source_line(classifier)
			item += classifier

		definition = nodes.definition()
		_stamp_source_line(definition)
		for n in _parse_doc_fragment(doc, state):
			definition += n
		item += definition
		dl += item

	section += _field_list("Attributes", dl)


def _find_proc_by_name(procedures, name: str):
	for p in procedures or []:
		if getattr(p, "name", None) == name:
			return p
	return None


def _proc_ref_node(target: str, all_procedures, *, text: str | None = None) -> nodes.Node:
	"""Return a cross-reference to ``target`` if it is a known procedure, else a plain literal."""
	label = nodes.literal(text=str(text or target))
	proc = _find_proc_by_name(all_procedures, target)
	kind = getattr(proc, "kind", None) if proc is not None else None
	if kind not in {"function", "subroutine"}:
		return label
	xref = addnodes.pending_xref(
		"",
		refdomain="f",
		reftype=str(kind),
		reftarget=str(target),
		refexplicit=True,
	)
	xref += label
	return xref


def _append_type_bound_procedures(section: nodes.Element, bindings, all_procedures, state) -> None:
	if not bindings:
		return

	items = nodes.bullet_list()
	for b in bindings:
		bname = getattr(b, "name", "")
		target = getattr(b, "target", None) or bname
		targets = tuple(getattr(b, "targets", ()) or ())
		if not targets and target:
			targets = (target,)
		if not bname:
			continue

		para = nodes.paragraph()
		if len(targets) == 1:
			para += _proc_ref_node(str(target), all_procedures, text=str(bname))
		else:
			para += nodes.literal(text=str(bname))

		if targets and not (len(targets) == 1 and targets[0] == bname):
			para += nodes.Text(" => ")
			for index, target_name in enumerate(targets):
				if index:
					para += nodes.Text(", ")
				para += _proc_ref_node(str(target_name), all_procedures)

		item = nodes.list_item("", para)
		items += item

	section += _field_list("Procedures", items)


def _append_interface_procedures(section: nodes.Element, names, all_procedures) -> None:
	"""Render the specific procedures lumped together by a generic interface."""
	if not names:
		return

	items = nodes.bullet_list()
	for name in names:
		items += nodes.list_item("", nodes.paragraph("", "", _proc_ref_node(str(name), all_procedures)))

	section += _field_list("Procedures", items)


def _append_object_description(
	section: nodes.Element,
	*,
	domain: str,
	objtype: str,
	name: str,
	signature: str | None,
	doc: str | None,
	state,
	args=None,
	result=None,
	components=None,
	bindings=None,
	all_procedures=None,
	specifics=None,
	bodies=None,
	anchor: str | None = None,
) -> None:
	"""Render an object in a Sphinx-like <dl class="..."> wrapper.

	For interfaces, ``specifics`` lists the lumped procedure names and ``bodies``
	is a sequence of ``(procedure, anchor)`` pairs rendered as nested objects.
	"""
	desc = addnodes.desc()
	desc["domain"] = domain
	desc["objtype"] = objtype
	desc["classes"].extend([domain, objtype])

	signode = addnodes.desc_signature()
	if anchor:
		signode["ids"].append(anchor)
	# Keep signature rendering simple and stable: show the parsed signature text
	# as a literal inside the signature node.
	text = signature if signature else f"{name} ({objtype})"
	signode += nodes.literal(text=str(text))
	desc += signode

	content = addnodes.desc_content()
	preamble_doc, section_blocks_doc = _split_out_doc_section_blocks(doc)
	# Keep the opening free-text docstring at the top.
	_append_doc(content, preamble_doc, state)
	_append_named_decl_docs(content, "Arguments", args, state)
	if objtype in {"function", "interface"}:
		_append_return_docs(content, result, state)
	_append_component_docs(content, components, state)
	_append_type_bound_procedures(content, bindings, all_procedures, state)
	_append_interface_procedures(content, specifics, all_procedures)
	for body, body_anchor in bodies or ():
		_append_object_description(
			content,
			domain=domain,
			objtype=str(getattr(body, "kind", "procedure")),
			name=str(body.name),
			signature=getattr(body, "signature", None),
			doc=getattr(body, "doc", None),
			state=state,
			args=getattr(body, "arguments", None),
			result=getattr(body, "result", None),
			anchor=body_anchor,
		)
	# Place all "## ..." section blocks (including "## Examples") after intrinsic blocks.
	_append_doc(content, section_blocks_doc, state)
	desc += content

	section += desc


def _is_private_member(item) -> bool:
	return bool(getattr(item, "is_private", False))

def _filter_visibility(items, *, show_private: bool):
	if not items:
		return []
	if show_private:
		return list(items)
	return [item for item in items if not _is_private_member(item)]


def _binding_target_names(binding) -> list[str]:
	targets = list(getattr(binding, "targets", ()) or ())
	if targets:
		return [str(t) for t in targets if str(t)]
	target = getattr(binding, "target", None)
	if target:
		return [str(target)]
	name = getattr(binding, "name", None)
	return [str(name)] if name else []


def _collect_exported_procedure_names_from_types(types, procedures, *, show_private: bool) -> set[str]:
	if show_private:
		return {str(getattr(proc, "name", "")) for proc in procedures or [] if getattr(proc, "name", "")}

	procedure_names = {str(getattr(proc, "name", "")) for proc in procedures or [] if getattr(proc, "name", "")}
	exported: set[str] = set()

	for fortran_type in _filter_visibility(types, show_private=False):
		visible_bindings = _filter_visibility(getattr(fortran_type, "bound_procedures", None), show_private=False)
		binding_by_name = {
			str(getattr(binding, "name", "")): binding
			for binding in visible_bindings
			if getattr(binding, "name", "")
		}
		pending = [name for binding in visible_bindings for name in _binding_target_names(binding)]
		seen: set[str] = set()

		while pending:
			name = str(pending.pop())
			if not name or name in seen:
				continue
			seen.add(name)
			if name in procedure_names:
				exported.add(name)
				continue
			binding = binding_by_name.get(name)
			if binding is not None:
				pending.extend(_binding_target_names(binding))

	return exported


def _collect_exported_procedure_names_from_interfaces(interfaces) -> set[str]:
	# Specific procedures are reachable through a public generic interface even
	# when they are private themselves.
	return {
		str(name)
		for iface in _filter_visibility(interfaces, show_private=False)
		for name in getattr(iface, "procedures", ()) or ()
		if name
	}


def _filter_exported_procedures(procedures, *, types, interfaces=None, show_private: bool):
	all_procedures = list(procedures or [])
	if show_private:
		return all_procedures
	exported_names = _collect_exported_procedure_names_from_types(types, all_procedures, show_private=show_private)
	exported_names |= _collect_exported_procedure_names_from_interfaces(interfaces)
	return [
		procedure
		for procedure in all_procedures
		if not _is_private_member(procedure) or str(getattr(procedure, "name", "")) in exported_names
	]


def _append_interface_section(section: nodes.Element, iface, *, scope_name: str, domain, index, procedures, state) -> None:
	"""Render a module/submodule interface along with the procedures it lumps together."""
	fullname = f"{scope_name}.{iface.name}"
	obj_anchor = _make_object_id("interface", fullname)
	domain.note_object(name=iface.name, objtype="interface", anchor=obj_anchor)
	index["entries"].append(("single", f"{iface.name} (interface)", obj_anchor, "", None))

	sub = nodes.section(ids=[obj_anchor])
	sub += nodes.title(text=f"{iface.name} (interface)")

	bodies = list(getattr(iface, "bodies", None) or ())
	specifics = list(getattr(iface, "procedures", None) or ())

	if len(bodies) == 1 and bodies[0].name == iface.name:
		# A single explicit interface (e.g. from an abstract interface block):
		# document the body directly as the interface.
		body = bodies[0]
		_append_object_description(
			sub,
			domain="f",
			objtype="interface",
			name=str(iface.name),
			signature=getattr(body, "signature", None),
			doc=getattr(iface, "doc", None) or getattr(body, "doc", None),
			state=state,
			args=getattr(body, "arguments", None),
			result=getattr(body, "result", None),
		)
		section += sub
		return

	body_entries = []
	for body in bodies:
		kind = str(getattr(body, "kind", "procedure"))
		body_anchor = _make_object_id(kind, f"{fullname}.{body.name}")
		domain.note_object(name=body.name, objtype=kind, anchor=body_anchor)
		index["entries"].append(("single", f"{body.name} ({kind})", body_anchor, "", None))
		body_entries.append((body, body_anchor))

	_append_object_description(
		sub,
		domain="f",
		objtype="interface",
		name=str(iface.name),
		signature=None,
		doc=getattr(iface, "doc", None),
		state=state,
		all_procedures=[*(procedures or ()), *bodies],
		specifics=specifics,
		bodies=body_entries,
	)
	section += sub

[docs] class FortranObject(ObjectDescription[str]): """Base class for manual Fortran object directives. Use these directives when you want to declare API objects directly in docs, instead of relying on parsed ``fortran_sources``. In reStructuredText: .. code-block:: rst .. f:function:: solve_linear_system(a, b) Solves ``Ax = b``. In MyST Markdown (requires MyST directive fences): .. code-block:: md ```{f:function} solve_linear_system(a, b) Solves `Ax = b`. ``` """ has_content = True required_arguments = 1
[docs] def handle_signature(self, sig: str, signode: addnodes.desc_signature) -> str: fullname = sig.strip() signode += addnodes.desc_name(fullname, fullname) return fullname
[docs] def add_target_and_index(self, name: str, sig: str, signode: addnodes.desc_signature) -> None: domain: FortranDomain = self.env.get_domain("f") # type: ignore[assignment] objtype = self.objtype anchor = _make_object_id(objtype, name) if anchor not in signode["ids"]: signode["ids"].append(anchor) domain.note_object(name=name, objtype=objtype, anchor=anchor) index_text = f"{name} ({objtype})" self.indexnode["entries"].append(("single", index_text, anchor, "", None))
[docs] class FortranFunction(FortranObject): """Manual declaration for a Fortran function. In reStructuredText: .. code-block:: rst .. f:function:: det(a) Computes the determinant of ``a``. In MyST Markdown: .. code-block:: md ```{f:function} det(a) Computes the determinant of `a`. ``` """ objtype = "function"
[docs] class FortranSubroutine(FortranObject): """Manual declaration for a Fortran subroutine. In reStructuredText: .. code-block:: rst .. f:subroutine:: normalize(vec) Normalizes ``vec`` in place. In MyST Markdown: .. code-block:: md ```{f:subroutine} normalize(vec) Normalizes `vec` in place. ``` """ objtype = "subroutine"
[docs] class FortranType(FortranObject): """Manual declaration for a Fortran derived type. In reStructuredText: .. code-block:: rst .. f:type:: mesh_t Unstructured mesh container. In MyST Markdown: .. code-block:: md ```{f:type} mesh_t Unstructured mesh container. ``` """ objtype = "type"
[docs] class FortranInterface(FortranObject): """Manual declaration for a Fortran interface or generic interface. In reStructuredText: .. code-block:: rst .. f:interface:: solve Generic interface for linear solvers. In MyST Markdown: .. code-block:: md ```{f:interface} solve Generic interface for linear solvers. ``` The specific procedures lumped together by the interface are listed under a "Procedures" field and linked to their documentation (parsed or declared with ``f:function``/``f:subroutine``). By default they are taken from the interface of the same name in ``fortran_sources`` (the first module, then submodule, defining it). Options: * ``:module:`` module or submodule in which to look up the parsed interface, when several of them define an interface with that name. * ``:procedures:`` comma-separated specific procedures, overriding the parsed list (e.g. to list only some of them, or to document an interface that is not parsed). Names unknown to the parsed interface are reported as warnings. .. code-block:: rst .. f:interface:: solve :module: linear_solvers :procedures: solve_real, solve_complex Generic interface for linear solvers. """ objtype = "interface" option_spec = { **FortranObject.option_spec, "module": rst_directives.unchanged_required, "procedures": rst_directives.unchanged, }
[docs] def transform_content(self, content_node: addnodes.desc_content) -> None: name = self.names[0] if self.names else self.arguments[0].strip() module = (self.options.get("module") or "").strip() or None parsed = self.env.get_domain("f").find_interface(name, module=module) # type: ignore[attr-defined] if parsed is None and module: logger.warning( "f:interface %s: no parsed interface of that name in module %s", name, module, location=self.get_location(), ) parsed_names = [str(n) for n in getattr(parsed, "procedures", None) or ()] if "procedures" in self.options: names = [n.strip() for n in re.split(r"[,\s]+", self.options["procedures"] or "") if n.strip()] if parsed is not None: known = {n.lower() for n in parsed_names} unknown = [n for n in names if n.lower() not in known] if unknown: logger.warning( "f:interface %s: %s not among the procedures of the parsed interface (%s)", name, ", ".join(unknown), ", ".join(parsed_names) or "none", location=self.get_location(), ) else: names = parsed_names if not names: return items = nodes.bullet_list() for name in names: xref = addnodes.pending_xref( "", refdomain="f", reftype="procedure", reftarget=name, refexplicit=True, ) xref += nodes.literal(text=name) items += nodes.list_item("", nodes.paragraph("", "", xref)) # Keep the list right after the free-text description, as in parsed interfaces. content_node += _field_list("Procedures", items)
[docs] class FortranProgram(Directive): """Render a parsed Fortran ``program``. This directive looks up a program from the domain parsed result (typically populated from ``fortran_sources``) and renders: * program title and docstring block, * optional source code, * ``use`` dependencies, * optional internal procedures. Options: * ``:no-show-code:`` hide the program source block. * ``:procedures:`` force rendering internal procedures. * ``:no-procedures:`` hide internal procedures. In reStructuredText: .. code-block:: rst .. f:program:: simulation_main :no-show-code: In MyST Markdown: .. code-block:: md ```{f:program} simulation_main :no-show-code: ``` """ required_arguments = 1 option_spec = { "procedures": rst_directives.flag, "no-procedures": rst_directives.flag, "no-show-code": rst_directives.flag, }
[docs] def run(self): progname = self.arguments[0] env = self.state.document.settings.env domain = env.get_domain("f") program = getattr(domain, "get_program")(progname) show_procedures = True if "no-procedures" in self.options: show_procedures = False if "procedures" in self.options: show_procedures = True anchor = nodes.make_id(f"f-program-{progname}") index = addnodes.index(entries=[("single", f"{progname} (program)", anchor, "", None)]) section = nodes.section(ids=[anchor]) section += nodes.title(text=f"{progname} (program)") getattr(domain, "note_object")(name=progname, objtype="program", anchor=anchor) if program is None: return [index, nodes.warning(text=f"Fortran program '{progname}' not found (did you configure fortran_sources?)")] markers = _doc_markers_from_env(env) src = getattr(program, "source", None) or _read_program_source_from_location(getattr(program, "location", None)) predoc: str | None = None if not src: src, predoc = _read_program_source_by_search(env, progname, doc_markers=markers) else: # If we have a usable file location, try to re-read to extract only the doc # block immediately preceding the program statement (important for FORD, # which may include in-body docs in program.doc). loc = getattr(program, "location", None) path = getattr(loc, "path", None) if loc is not None else None lineno = getattr(loc, "lineno", None) if loc is not None else None if path and lineno: try: lines = Path(str(path)).read_text(encoding="utf-8", errors="replace").splitlines() start = _find_program_in_file(lines, progname, start_at=max(int(lineno) - 1 - 5, 0)) if start is not None: predoc = extract_predoc_before_line(lines, start, doc_markers=markers) except OSError: pass # Render program-level docs right under the title. # Prefer only the pre-program doc block when available (prevents in-body docs # from being rendered separately, which is especially important for FORD). doc = predoc if predoc is not None else getattr(program, "doc", None) _append_doc(section, doc, self.state) show_source_code = "no-show-code" not in self.options if src and show_source_code: _append_fortran_code_block(section, source=src) deps = list(getattr(program, "dependencies", None) or []) if not deps and src: deps = extract_use_dependencies(src) _append_program_dependencies(section, dependencies=deps, state=self.state) # Internal procedures (after `contains`) are optionally rendered after the program source. if show_procedures and getattr(program, "procedures", None): section += nodes.subtitle(text="Procedures") for p in program.procedures: kind = getattr(p, "kind", "procedure") fullname = f"{progname}.{p.name}" obj_anchor = _make_object_id(kind, fullname) getattr(domain, "note_object")(name=p.name, objtype=kind, anchor=obj_anchor) index["entries"].append(("single", f"{p.name} ({kind})", obj_anchor, "", None)) sub = nodes.section(ids=[obj_anchor]) sub += nodes.title(text=f"{p.name} ({kind})") _append_object_description( sub, domain="f", objtype=str(kind), name=str(p.name), signature=getattr(p, "signature", None), doc=getattr(p, "doc", None), state=self.state, args=getattr(p, "arguments", None), result=getattr(p, "result", None), ) section += sub return [index, section]
[docs] class FortranModule(Directive): """Render a parsed Fortran module and its public members. This directive resolves a module from parsed sources and renders module docs, variables, derived types, interfaces, and procedures in one section. Options: * ``:show-private:`` include private module members and private type internals (components/type-bound procedures). By default, private elements are hidden. In reStructuredText: .. code-block:: rst .. f:module:: linalg :show-private: In MyST Markdown: .. code-block:: md ```{f:module} linalg :show-private: ``` """ required_arguments = 1 option_spec = { "show-private": rst_directives.flag, }
[docs] def run(self): modname = self.arguments[0] env = self.state.document.settings.env domain = env.get_domain("f") module = getattr(domain, "get_module")(modname) show_private = "show-private" in self.options anchor = nodes.make_id(f"f-module-{modname}") index = addnodes.index(entries=[("single", f"{modname} (module)", anchor, "", None)]) section = nodes.section(ids=[anchor]) section += nodes.title(text=f"{modname} (module)") getattr(domain, "note_object")(name=modname, objtype="module", anchor=anchor) if module is None: return [index, nodes.warning(text=f"Fortran module '{modname}' not found (did you configure fortran_sources?)")] _append_doc(section, getattr(module, "doc", None), self.state) variables = _filter_visibility(getattr(module, "variables", None), show_private=show_private) if variables: anchors: dict[str, str] = {} for v in variables: name = getattr(v, "name", "") if not name: continue fullname = f"{modname}.{name}" obj_anchor = _make_object_id("variable", fullname) anchors[str(name)] = obj_anchor getattr(domain, "note_object")(name=name, objtype="variable", anchor=obj_anchor) index["entries"].append(("single", f"{name} (variable)", obj_anchor, "", None)) _append_named_decl_docs( section, "Variables", variables, self.state, anchors_by_name=anchors, as_field_list=False, ) types = _filter_visibility(getattr(module, "types", None), show_private=show_private) procedures = _filter_exported_procedures( getattr(module, "procedures", None), types=getattr(module, "types", None), interfaces=getattr(module, "interfaces", None), show_private=show_private, ) interfaces = _filter_visibility(getattr(module, "interfaces", None), show_private=show_private) if types: section += nodes.subtitle(text="Types") for t in types: fullname = f"{modname}.{t.name}" obj_anchor = _make_object_id("type", fullname) getattr(domain, "note_object")(name=t.name, objtype="type", anchor=obj_anchor) index["entries"].append(("single", f"{t.name} (type)", obj_anchor, "", None)) sub = nodes.section(ids=[obj_anchor]) sub += nodes.title(text=f"{t.name} (type)") _append_object_description( sub, domain="f", objtype="type", name=str(t.name), signature=getattr(t, "signature", None), doc=getattr(t, "doc", None), state=self.state, components=_filter_visibility(getattr(t, "components", None), show_private=show_private), bindings=_filter_visibility(getattr(t, "bound_procedures", None), show_private=show_private), all_procedures=procedures, ) section += sub if interfaces: section += nodes.subtitle(text="Interfaces") for g in interfaces: _append_interface_section( section, g, scope_name=modname, domain=domain, index=index, procedures=procedures, state=self.state, ) if procedures: section += nodes.subtitle(text="Procedures") for p in procedures: kind = getattr(p, "kind", "procedure") fullname = f"{modname}.{p.name}" obj_anchor = _make_object_id(kind, fullname) getattr(domain, "note_object")(name=p.name, objtype=kind, anchor=obj_anchor) index["entries"].append(("single", f"{p.name} ({kind})", obj_anchor, "", None)) sub = nodes.section(ids=[obj_anchor]) sub += nodes.title(text=f"{p.name} ({kind})") _append_object_description( sub, domain="f", objtype=str(kind), name=str(p.name), signature=getattr(p, "signature", None), doc=getattr(p, "doc", None), state=self.state, args=getattr(p, "arguments", None), result=getattr(p, "result", None), ) section += sub return [index, section]
[docs] class FortranSubmodule(Directive): """Render a parsed Fortran submodule and its members. Use this for projects that split implementation into submodules and want automatic API pages similar to ``f:module``. Options: * ``:show-private:`` include private submodule members and private type internals (components/type-bound procedures). By default, private elements are hidden. In reStructuredText: .. code-block:: rst .. f:submodule:: linalg_impl :show-private: In MyST Markdown: .. code-block:: md ```{f:submodule} linalg_impl :show-private: ``` """ required_arguments = 1 option_spec = { "show-private": rst_directives.flag, }
[docs] def run(self): submodname = self.arguments[0] env = self.state.document.settings.env domain = env.get_domain("f") submodule = getattr(domain, "get_submodule")(submodname) show_private = "show-private" in self.options anchor = nodes.make_id(f"f-submodule-{submodname}") index = addnodes.index(entries=[("single", f"{submodname} (submodule)", anchor, "", None)]) section = nodes.section(ids=[anchor]) section += nodes.title(text=f"{submodname} (submodule)") getattr(domain, "note_object")(name=submodname, objtype="submodule", anchor=anchor) if submodule is None: return [index, nodes.warning(text=f"Fortran submodule '{submodname}' not found (did you configure fortran_sources?)")] _append_doc(section, getattr(submodule, "doc", None), self.state) variables = _filter_visibility(getattr(submodule, "variables", None), show_private=show_private) if variables: anchors: dict[str, str] = {} for v in variables: name = getattr(v, "name", "") if not name: continue fullname = f"{submodname}.{name}" obj_anchor = _make_object_id("variable", fullname) anchors[str(name)] = obj_anchor getattr(domain, "note_object")(name=name, objtype="variable", anchor=obj_anchor) index["entries"].append(("single", f"{name} (variable)", obj_anchor, "", None)) _append_named_decl_docs( section, "Variables", variables, self.state, anchors_by_name=anchors, as_field_list=False, ) types = _filter_visibility(getattr(submodule, "types", None), show_private=show_private) procedures = _filter_exported_procedures( getattr(submodule, "procedures", None), types=getattr(submodule, "types", None), interfaces=getattr(submodule, "interfaces", None), show_private=show_private, ) interfaces = _filter_visibility(getattr(submodule, "interfaces", None), show_private=show_private) if types: section += nodes.subtitle(text="Types") for t in types: fullname = f"{submodname}.{t.name}" obj_anchor = _make_object_id("type", fullname) getattr(domain, "note_object")(name=t.name, objtype="type", anchor=obj_anchor) index["entries"].append(("single", f"{t.name} (type)", obj_anchor, "", None)) sub = nodes.section(ids=[obj_anchor]) sub += nodes.title(text=f"{t.name} (type)") _append_object_description( sub, domain="f", objtype="type", name=str(t.name), signature=getattr(t, "signature", None), doc=getattr(t, "doc", None), state=self.state, components=_filter_visibility(getattr(t, "components", None), show_private=show_private), bindings=_filter_visibility(getattr(t, "bound_procedures", None), show_private=show_private), all_procedures=procedures, ) section += sub if interfaces: section += nodes.subtitle(text="Interfaces") for g in interfaces: _append_interface_section( section, g, scope_name=submodname, domain=domain, index=index, procedures=procedures, state=self.state, ) if procedures: section += nodes.subtitle(text="Procedures") for p in procedures: kind = getattr(p, "kind", "procedure") fullname = f"{submodname}.{p.name}" obj_anchor = _make_object_id(kind, fullname) getattr(domain, "note_object")(name=p.name, objtype=kind, anchor=obj_anchor) index["entries"].append(("single", f"{p.name} ({kind})", obj_anchor, "", None)) sub = nodes.section(ids=[obj_anchor]) sub += nodes.title(text=f"{p.name} ({kind})") _append_object_description( sub, domain="f", objtype=str(kind), name=str(p.name), signature=getattr(p, "signature", None), doc=getattr(p, "doc", None), state=self.state, args=getattr(p, "arguments", None), result=getattr(p, "result", None), ) section += sub return [index, section]