From 83dea2bd18ffc092722ff3e28ba957ddf0bc92d4 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Mon, 8 Dec 2025 12:06:01 +0100 Subject: [PATCH 1/3] improve DocumenterReference --- Project.toml | 2 +- docs/make.jl | 39 +- ext/DocumenterReference.jl | 1238 ++++++++++++++++++++---------------- 3 files changed, 720 insertions(+), 559 deletions(-) diff --git a/Project.toml b/Project.toml index 166f97e6..5e4b1a05 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTBase" uuid = "54762871-cc72-4466-b8e8-f6c8b58076cd" -version = "0.17.0" +version = "0.17.1" authors = ["Olivier Cots ", "Jean-Baptiste Caillau "] [deps] diff --git a/docs/make.jl b/docs/make.jl index 5741616f..af875cb1 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -23,6 +23,18 @@ end # Repository configuration # ═══════════════════════════════════════════════════════════════════════════════ repo_url = "github.com/control-toolbox/CTBase.jl" +src_dir = abspath(joinpath(@__DIR__, "..", "src")) +ext_dir = abspath(joinpath(@__DIR__, "..", "ext")) + +# Helper to build absolute paths +src(files...) = [abspath(joinpath(src_dir, f)) for f in files] +ext(files...) = [abspath(joinpath(ext_dir, f)) for f in files] + +# Symbols to exclude from documentation (auto-generated by @with_kw, etc.) +const EXCLUDE_SYMBOLS = Symbol[ + :include, + :eval, +] # ═══════════════════════════════════════════════════════════════════════════════ # Build documentation @@ -46,59 +58,54 @@ makedocs(; "API Reference" => [ CTBase.automatic_reference_documentation(; subdirectory=".", - modules=[CTBase], - exclude=Symbol[:include, :eval], + primary_modules=[CTBase => src("CTBase.jl")], + exclude=EXCLUDE_SYMBOLS, public=false, private=true, title="CTBase", title_in_menu="CTBase", # optionnel filename="ctbase", - source_files=[abspath(joinpath(@__DIR__, "..", "src", "CTBase.jl"))], ), CTBase.automatic_reference_documentation(; subdirectory=".", - modules=[CTBase], - exclude=Symbol[:include, :eval], + primary_modules=[CTBase => src("default.jl")], + exclude=EXCLUDE_SYMBOLS, public=false, private=true, title="Default", title_in_menu="Default", # optionnel filename="default", - source_files=[abspath(joinpath(@__DIR__, "..", "src", "default.jl"))], ), CTBase.automatic_reference_documentation(; subdirectory=".", - modules=[CTBase], - exclude=Symbol[:include, :eval], + primary_modules=[CTBase => src("description.jl")], + exclude=EXCLUDE_SYMBOLS, public=false, private=true, title="Description", title_in_menu="Description", # optionnel filename="description", - source_files=[abspath(joinpath(@__DIR__, "..", "src", "description.jl"))], ), CTBase.automatic_reference_documentation(; subdirectory=".", - modules=[CTBase], - doc_modules=[Base], # pour inclure Base.showerror - exclude=Symbol[:include, :eval], + primary_modules=[CTBase => src("exception.jl")], + external_modules_to_document=[Base], # pour inclure Base.showerror + exclude=EXCLUDE_SYMBOLS, public=false, private=true, title="Exception", title_in_menu="Exception", # optionnel filename="exception", - source_files=[abspath(joinpath(@__DIR__, "..", "src", "exception.jl"))], ), CTBase.automatic_reference_documentation(; subdirectory=".", - modules=[CTBase], - exclude=Symbol[:include, :eval], + primary_modules=[CTBase => src("utils.jl")], + exclude=EXCLUDE_SYMBOLS, public=false, private=true, title="Utils", title_in_menu="Utils", # optionnel filename="utils", - source_files=[abspath(joinpath(@__DIR__, "..", "src", "utils.jl"))], ), ], ], diff --git a/ext/DocumenterReference.jl b/ext/DocumenterReference.jl index cc096937..2cd63547 100644 --- a/ext/DocumenterReference.jl +++ b/ext/DocumenterReference.jl @@ -8,6 +8,11 @@ # - Added robust handling for missing docstrings (warnings instead of errors) # - Included non-exported symbols in API reference # - Filtered internal compiler-generated symbols (starting with '#') +# +# Refactored December 2025: +# - Extracted helper functions to reduce code duplication +# - Improved documentation and code organization +# - Added Dict-based DocType to string conversion module DocumenterReference @@ -16,6 +21,10 @@ using Documenter: Documenter using Markdown: Markdown using MarkdownAST: MarkdownAST +# ═══════════════════════════════════════════════════════════════════════════════ +# Types and Constants +# ═══════════════════════════════════════════════════════════════════════════════ + """ DocType @@ -40,6 +49,35 @@ Enumeration of documentation element types recognized by the API reference gener DOCTYPE_STRUCT, ) +""" + DOCTYPE_NAMES::Dict{DocType, String} + +Mapping from DocType enum values to their human-readable string representations. +""" +const DOCTYPE_NAMES = Dict{DocType, String}( + DOCTYPE_ABSTRACT_TYPE => "abstract type", + DOCTYPE_CONSTANT => "constant", + DOCTYPE_FUNCTION => "function", + DOCTYPE_MACRO => "macro", + DOCTYPE_MODULE => "module", + DOCTYPE_STRUCT => "struct", +) + +""" + DOCTYPE_ORDER::Dict{DocType, Int} + +Ordering for DocType values used when sorting symbols for display. +Lower values appear first. +""" +const DOCTYPE_ORDER = Dict{DocType, Int}( + DOCTYPE_MODULE => 0, + DOCTYPE_MACRO => 1, + DOCTYPE_FUNCTION => 2, + DOCTYPE_ABSTRACT_TYPE => 3, + DOCTYPE_STRUCT => 4, + DOCTYPE_CONSTANT => 5, +) + """ _Config @@ -49,22 +87,23 @@ Internal configuration for API reference generation. - `current_module::Module`: The module being documented. - `subdirectory::String`: Output directory for generated API pages. -- `modules::Dict{Module,<:Vector}`: Mapping of modules to extras (reserved for future use). +- `modules::Dict{Module,Vector{String}}`: Mapping of modules to their source files. + When a module is specified as `Module => files`, the files are stored here. - `sort_by::Function`: Custom sort function for symbols. - `exclude::Set{Symbol}`: Symbol names to exclude from documentation. - `public::Bool`: Flag to generate public API page. - `private::Bool`: Flag to generate private API page. - `title::String`: Title displayed at the top of the generated page. - `title_in_menu::String`: Title displayed in the navigation menu. -- `source_files::Vector{String}`: Absolute source file paths used to filter documented symbols (empty means no filtering). +- `source_files::Vector{String}`: Global source file paths (fallback if no module-specific files). - `filename::String`: Base filename (without extension) for the markdown file. - `include_without_source::Bool`: If `true`, include symbols whose source file cannot be determined. -- `doc_modules::Vector{Module}`: Additional modules to search for docstrings (e.g., `Base`). +- `external_modules_to_document::Vector{Module}`: Additional modules to search for docstrings. """ struct _Config current_module::Module subdirectory::String - modules::Dict{Module,<:Vector} + modules::Dict{Module,Vector{String}} sort_by::Function exclude::Set{Symbol} public::Bool @@ -73,8 +112,8 @@ struct _Config title_in_menu::String source_files::Vector{String} filename::String - include_without_source::Bool # Include symbols whose source file cannot be determined - doc_modules::Vector{Module} # Additional modules to search for docstrings (e.g., Base) + include_without_source::Bool + external_modules_to_document::Vector{Module} end """ @@ -88,134 +127,67 @@ entry to this vector. Use [`reset_config!`](@ref) to clear it between builds. const CONFIG = _Config[] """ - reset_config!() + PAGE_CONTENT_ACCUMULATOR::Dict{String, Vector{Tuple{Module, Vector{String}, Vector{String}}}} -Clear the global `CONFIG` vector. Useful between documentation builds or for testing. +Global accumulator for multi-module combined pages. +Maps output filename to a list of (module, public_docstrings, private_docstrings) tuples. """ -function reset_config!() - empty!(CONFIG) - return nothing -end - -""" - APIBuilder <: Documenter.Builder.DocumentPipeline - -Custom Documenter pipeline stage for automatic API reference generation. - -This builder is inserted into the Documenter pipeline at order `0.0` (before -most other stages) to generate API reference pages from the configurations -stored in [`CONFIG`](@ref). -""" -abstract type APIBuilder <: Documenter.Builder.DocumentPipeline end - -""" - Documenter.Selectors.order(::Type{APIBuilder}) -> Float64 - -Return the pipeline order for [`APIBuilder`](@ref). - -# Returns - -- `Float64`: Always `0.0`, placing this stage early in the Documenter pipeline. -""" -Documenter.Selectors.order(::Type{APIBuilder}) = 0.0 - -""" - _default_basename(filename::String, public::Bool, private::Bool) -> String - -Compute the default base filename for the generated markdown file. - -# Logic -- If `filename` is non-empty, use it. -- If only `public` is true, use `"public"`. -- If only `private` is true, use `"private"`. -- If both are true, use `"api"`. +const PAGE_CONTENT_ACCUMULATOR = Dict{String, Vector{Tuple{Module, Vector{String}, Vector{String}}}}() -# Arguments -- `filename::String`: User-provided filename (may be empty) -- `public::Bool`: Whether public API is requested -- `private::Bool`: Whether private API is requested +# ═══════════════════════════════════════════════════════════════════════════════ +# Public API +# ═══════════════════════════════════════════════════════════════════════════════ -# Returns -- `String`: The base filename to use (without extension) """ -function _default_basename(filename::String, public::Bool, private::Bool) - if filename != "" - return filename - elseif public && private - return "api" - elseif public - return "public" - else - return "private" - end -end - -""" - _build_page_path(subdirectory::String, filename::String) -> String - -Build the page path by joining subdirectory and filename. - -Handles special cases where `subdirectory` is `"."` or empty, returning just -the filename in those cases. - -# Arguments - -- `subdirectory::String`: Directory path (may be `"."` or empty). -- `filename::String`: The filename to append. - -# Returns + reset_config!() -- `String`: The combined path, or just `filename` if subdirectory is `"."` or empty. +Clear the global `CONFIG` vector and `PAGE_CONTENT_ACCUMULATOR`. +Useful between documentation builds or for testing. """ -function _build_page_path(subdirectory::String, filename::String) - if subdirectory == "." || subdirectory == "" - return filename - else - return "$subdirectory/$filename" - end +function reset_config!() + empty!(CONFIG) + empty!(PAGE_CONTENT_ACCUMULATOR) + return nothing end """ automatic_reference_documentation(; subdirectory::String, - modules, + primary_modules, sort_by::Function = identity, exclude::Vector{Symbol} = Symbol[], public::Bool = true, private::Bool = true, title::String = "API Reference", + title_in_menu::String = "", filename::String = "", source_files::Vector{String} = String[], include_without_source::Bool = false, - doc_modules::Vector{Module} = Module[], + external_modules_to_document::Vector{Module} = Module[], ) Automatically creates the API reference documentation for one or more modules and -returns a `Vector` which can be used in the `pages` argument of -`Documenter.makedocs`. +returns a structure which can be used in the `pages` argument of `Documenter.makedocs`. ## Arguments * `subdirectory`: the directory relative to the documentation root in which to write the API files. - * `modules`: a vector of modules or `module => extras` pairs. Extras are - currently unused but reserved for future extensions. + * `primary_modules`: a vector of modules or `Module => source_files` pairs to document. + When source files are provided, only symbols defined in those files are documented. * `sort_by`: a custom sort function applied to symbol lists. - * `exclude`: vector of symbol names to skip from the generated API (applied to - both public and private symbols). + * `exclude`: vector of symbol names to skip from the generated API. * `public`: flag to generate public API page (default: `true`). * `private`: flag to generate private API page (default: `true`). - * `title`: title displayed at the top of the generated page (default: "API Reference"). + * `title`: title displayed at the top of the generated page. * `title_in_menu`: title displayed in the navigation menu (default: same as `title`). - * `filename`: base filename (without extension) used for the underlying markdown - file. Defaults: `"public"` if only public, `"private"` if only private, - `"api"` if both. - * `source_files`: source file paths to filter documented symbols. Only symbols - defined in these files will be included. Paths are normalized to absolute. + * `filename`: base filename (without extension) for the markdown file. + * `source_files`: global source file paths (fallback if no module-specific files). + **Deprecated**: prefer using `primary_modules=[Module => files]` instead. * `include_without_source`: if `true`, include symbols whose source file cannot - be determined (e.g., abstract types, some constants). Default: `false`. - * `doc_modules`: additional modules to search for docstrings (e.g., `[Base]` to - include `Base.showerror` documentation). Default: empty. + be determined. Default: `false`. + * `external_modules_to_document`: additional modules to search for docstrings + (e.g., `[Plots]` to include `Plots.plot` methods defined in your source files). ## Multiple instances @@ -225,7 +197,7 @@ Each time you call this function, a new object is added to the global variable function CTBase.automatic_reference_documentation( ::CTBase.DocumenterReferenceTag; subdirectory::String, - modules::Vector, + primary_modules::Vector, sort_by::Function=identity, exclude::Vector{Symbol}=Symbol[], public::Bool=true, @@ -235,589 +207,771 @@ function CTBase.automatic_reference_documentation( filename::String="", source_files::Vector{String}=String[], include_without_source::Bool=false, - doc_modules::Vector{Module}=Module[], + external_modules_to_document::Vector{Module}=Module[], ) - _to_extras(m::Module) = m => Any[] - _to_extras(m::Pair) = m - _modules = Dict(_to_extras(m) for m in modules) - exclude_set = Set(exclude) - normalized_source_files = - isempty(source_files) ? String[] : [abspath(path) for path in source_files] - + # Validate arguments if !public && !private - error( - "automatic_reference_documentation: both `public` and `private` cannot be false.", - ) + error("automatic_reference_documentation: both `public` and `private` cannot be false.") end - # Effective title_in_menu defaults to title if not provided - effective_title_in_menu = title_in_menu == "" ? title : title_in_menu - - # For single-module case, return structure directly based on public/private flags - if length(modules) == 1 - current_module = first(_to_extras(modules[1])) - # Compute effective filename using defaults - effective_filename = _default_basename(filename, public, private) - push!( - CONFIG, - _Config( - current_module, - subdirectory, - _modules, - sort_by, - exclude_set, - public, - private, - title, - effective_title_in_menu, - normalized_source_files, - effective_filename, - include_without_source, - doc_modules, - ), + # Parse primary_modules into a Dict{Module, Vector{String}} + modules_dict = _parse_primary_modules(primary_modules) + exclude_set = Set(exclude) + normalized_source_files = _normalize_paths(source_files) + effective_title_in_menu = isempty(title_in_menu) ? title : title_in_menu + effective_filename = _default_basename(filename, public, private) + + # Single-module case + if length(primary_modules) == 1 + current_module = first(keys(modules_dict)) + _register_config( + current_module, subdirectory, modules_dict, sort_by, exclude_set, + public, private, title, effective_title_in_menu, normalized_source_files, + effective_filename, include_without_source, external_modules_to_document ) - if public && private - # Both pages: use subdirectory with public.md and private.md - return effective_title_in_menu => [ - "Public" => _build_page_path(subdirectory, "public.md"), - "Private" => _build_page_path(subdirectory, "private.md"), - ] - elseif public - return effective_title_in_menu => - _build_page_path(subdirectory, "$effective_filename.md") - else - return effective_title_in_menu => - _build_page_path(subdirectory, "$effective_filename.md") + return _build_page_return_structure(effective_title_in_menu, subdirectory, effective_filename, public, private) + end + + # Multi-module case with combined page (filename provided) + if !isempty(filename) + for mod in keys(modules_dict) + _register_config( + mod, subdirectory, modules_dict, sort_by, exclude_set, + public, private, title, effective_title_in_menu, normalized_source_files, + effective_filename, include_without_source, external_modules_to_document + ) end + return _build_page_return_structure(effective_title_in_menu, subdirectory, effective_filename, public, private) end - # For multi-module case, create a per-module subdirectory + # Multi-module case with per-module subdirectories list_of_pages = Any[] - for m in modules - current_module = first(_to_extras(m)) - module_subdir = joinpath(subdirectory, string(current_module)) - pages = _automatic_reference_documentation( - current_module; - subdirectory=module_subdir, - modules=_modules, - sort_by, - exclude=exclude_set, - public, - private, - source_files=normalized_source_files, - include_without_source, - doc_modules, + for mod in keys(modules_dict) + module_subdir = joinpath(subdirectory, string(mod)) + module_filename = _default_basename("", public, private) + default_title = _default_title(public, private) + + _register_config( + mod, module_subdir, modules_dict, sort_by, exclude_set, + public, private, default_title, default_title, normalized_source_files, + module_filename, include_without_source, external_modules_to_document ) - push!(list_of_pages, "$current_module" => pages) + + pages = _build_page_return_structure(default_title, module_subdir, module_filename, public, private) + push!(list_of_pages, string(mod) => last(pages)) end return effective_title_in_menu => list_of_pages end +# ═══════════════════════════════════════════════════════════════════════════════ +# Documenter Pipeline Integration +# ═══════════════════════════════════════════════════════════════════════════════ + +""" + APIBuilder <: Documenter.Builder.DocumentPipeline + +Custom Documenter pipeline stage for automatic API reference generation. + +This builder is inserted into the Documenter pipeline at order `0.0` (before +most other stages) to generate API reference pages from the configurations +stored in [`CONFIG`](@ref). +""" +abstract type APIBuilder <: Documenter.Builder.DocumentPipeline end + +""" + Documenter.Selectors.order(::Type{APIBuilder}) -> Float64 + +Return the pipeline order for [`APIBuilder`](@ref). +Returns `0.0`, placing this stage early in the Documenter pipeline. +""" +Documenter.Selectors.order(::Type{APIBuilder}) = 0.0 + +""" + Documenter.Selectors.runner(::Type{APIBuilder}, document) + +Documenter pipeline runner for API reference generation. +Processes all registered module configurations and generates their API reference pages. +""" +function Documenter.Selectors.runner(::Type{APIBuilder}, document::Documenter.Document) + @info "APIBuilder: creating API reference" + for config in CONFIG + _build_api_page(document, config) + end + _finalize_api_pages(document) + return nothing +end + +# ═══════════════════════════════════════════════════════════════════════════════ +# Helper Functions: Configuration +# ═══════════════════════════════════════════════════════════════════════════════ + """ - _automatic_reference_documentation(current_module; subdirectory, modules, sort_by, exclude) + _parse_primary_modules(primary_modules::Vector) -> Dict{Module, Vector{String}} -Internal helper for single-module API reference generation. +Parse the `primary_modules` argument into a dictionary mapping modules to their source files. +Handles both plain modules and `Module => files` pairs. +""" +function _parse_primary_modules(primary_modules::Vector) + result = Dict{Module, Vector{String}}() + for m in primary_modules + if m isa Module + result[m] = String[] + elseif m isa Pair + mod = first(m) + files = last(m) + result[mod] = _normalize_paths(files isa Vector ? files : [files]) + else + error("Invalid element in primary_modules: expected Module or Module => files pair") + end + end + return result +end -Registers the module configuration and returns the output path for the generated documentation. +""" + _normalize_paths(paths) -> Vector{String} -# Arguments -- `current_module::Module`: Module to document -- `subdirectory::String`: Output directory for API pages -- `modules::Dict{Module,<:Vector}`: Module mapping -- `sort_by::Function`: Custom sort function -- `exclude::Set{Symbol}`: Symbols to exclude -- `public::Bool`: Flag to generate public API page -- `private::Bool`: Flag to generate private API page -- `source_files::Vector{String}`: Absolute source file paths used to filter documented symbols (empty means no filtering) +Normalize a collection of paths to absolute paths. +""" +function _normalize_paths(paths) + isempty(paths) ? String[] : [abspath(p) for p in paths] +end -# Returns -- `String`: Path to the generated API documentation file """ -function _automatic_reference_documentation( - current_module::Module; + _register_config(current_module, subdirectory, modules, sort_by, exclude, public, private, + title, title_in_menu, source_files, filename, include_without_source, + external_modules_to_document) + +Create and register a `_Config` in the global `CONFIG` vector. +""" +function _register_config( + current_module::Module, subdirectory::String, - modules::Dict{Module,<:Vector}, + modules::Dict{Module, Vector{String}}, sort_by::Function, exclude::Set{Symbol}, public::Bool, private::Bool, + title::String, + title_in_menu::String, source_files::Vector{String}, - include_without_source::Bool=false, - doc_modules::Vector{Module}=Module[], + filename::String, + include_without_source::Bool, + external_modules_to_document::Vector{Module}, ) - effective_filename = _default_basename("", public, private) - # For multi-module case, use default titles - default_title = if public && !private - "Public API" - else - (!public && private ? "Private API" : "API Reference") - end - push!( - CONFIG, - _Config( - current_module, - subdirectory, - modules, - sort_by, - exclude, - public, - private, - default_title, - default_title, - source_files, - effective_filename, - include_without_source, - doc_modules, - ), - ) + push!(CONFIG, _Config( + current_module, subdirectory, modules, sort_by, exclude, + public, private, title, title_in_menu, source_files, filename, + include_without_source, external_modules_to_document + )) + return nothing +end + +""" + _default_basename(filename::String, public::Bool, private::Bool) -> String + +Compute the default base filename for the generated markdown file. +""" +function _default_basename(filename::String, public::Bool, private::Bool) + !isempty(filename) && return filename + public && private && return "api" + public && return "public" + return "private" +end + +""" + _default_title(public::Bool, private::Bool) -> String + +Compute the default title based on public/private flags. +""" +function _default_title(public::Bool, private::Bool) + public && !private && return "Public API" + !public && private && return "Private API" + return "API Reference" +end + +""" + _build_page_path(subdirectory::String, filename::String) -> String + +Build the page path by joining subdirectory and filename. +Handles special cases where `subdirectory` is `"."` or empty. +""" +function _build_page_path(subdirectory::String, filename::String) + (subdirectory == "." || isempty(subdirectory)) && return filename + return "$subdirectory/$filename" +end + +""" + _build_page_return_structure(title_in_menu, subdirectory, filename, public, private) -> Pair + +Build the return structure for `automatic_reference_documentation`. +""" +function _build_page_return_structure(title_in_menu::String, subdirectory::String, filename::String, public::Bool, private::Bool) if public && private - return [ + return title_in_menu => [ "Public" => _build_page_path(subdirectory, "public.md"), "Private" => _build_page_path(subdirectory, "private.md"), ] - elseif public - return _build_page_path(subdirectory, "$effective_filename.md") else - return _build_page_path(subdirectory, "$effective_filename.md") + return title_in_menu => _build_page_path(subdirectory, "$filename.md") end end +""" + _get_effective_source_files(config::_Config) -> Vector{String} + +Determine the effective source files for filtering symbols. +Priority: module-specific files > global source_files > empty (no filtering). +""" +function _get_effective_source_files(config::_Config) + module_files = get(config.modules, config.current_module, String[]) + !isempty(module_files) && return module_files + !isempty(config.source_files) && return config.source_files + return String[] +end + +# ═══════════════════════════════════════════════════════════════════════════════ +# Helper Functions: Symbol Classification +# ═══════════════════════════════════════════════════════════════════════════════ + +""" + _to_string(x::DocType) -> String + +Convert a DocType enumeration value to its string representation. +""" +_to_string(x::DocType) = DOCTYPE_NAMES[x] + """ _classify_symbol(obj, name_str::String) -> DocType Classify a symbol by its type (function, macro, struct, constant, module, abstract type). +""" +function _classify_symbol(obj, name_str::String) + startswith(name_str, "@") && return DOCTYPE_MACRO + obj isa Module && return DOCTYPE_MODULE + obj isa Type && isabstracttype(obj) && return DOCTYPE_ABSTRACT_TYPE + obj isa Type && return DOCTYPE_STRUCT + obj isa Function && return DOCTYPE_FUNCTION + return DOCTYPE_CONSTANT +end -# Arguments -- `obj`: The object bound to the symbol -- `name_str::String`: String representation of the symbol name +""" + _exported_symbols(mod::Module) -> NamedTuple -# Returns -- `DocType`: The classification of the symbol +Classify all symbols in a module into exported and private categories. +Returns a NamedTuple with `exported` and `private` fields, each containing +sorted lists of `(Symbol, DocType)` pairs. """ -function _classify_symbol(obj, name_str::String) - # Check for macro (name starts with @) - if startswith(name_str, "@") - return DOCTYPE_MACRO - end - # Check for module - if obj isa Module - return DOCTYPE_MODULE - end - # Check for abstract type - if obj isa Type && isabstracttype(obj) - return DOCTYPE_ABSTRACT_TYPE - end - # Check for concrete type / struct - if obj isa Type - return DOCTYPE_STRUCT - end - # Check for function - if obj isa Function - return DOCTYPE_FUNCTION +function _exported_symbols(mod::Module) + exported = Pair{Symbol,DocType}[] + private = Pair{Symbol,DocType}[] + exported_names = Set(names(mod; all=false)) + + for n in names(mod; all=true, imported=false) + name_str = String(n) + # Skip compiler-generated symbols and the module itself + startswith(name_str, "#") && continue + n == nameof(mod) && continue + + obj = try + getfield(mod, n) + catch + continue + end + + doc_type = _classify_symbol(obj, name_str) + target = n in exported_names ? exported : private + push!(target, n => doc_type) end - # Everything else is a constant - return DOCTYPE_CONSTANT + + sort_fn = x -> (DOCTYPE_ORDER[x[2]], string(x[1])) + return (exported=sort(exported; by=sort_fn), private=sort(private; by=sort_fn)) end +# ═══════════════════════════════════════════════════════════════════════════════ +# Helper Functions: Source File Detection +# ═══════════════════════════════════════════════════════════════════════════════ + """ _get_source_file(mod::Module, key::Symbol, type::DocType) -> Union{String, Nothing} Determine the source file path where a symbol is defined. - -Supports functions, types (via constructors), macros, and constants. Returns `nothing` if the source file cannot be determined. - -# Arguments -- `mod::Module`: The module containing the symbol -- `key::Symbol`: The symbol name -- `type::DocType`: The type classification of the symbol - -# Returns -- `Union{String, Nothing}`: Absolute path to the source file, or `nothing` """ function _get_source_file(mod::Module, key::Symbol, type::DocType) try - # Strategy 1 (most reliable): Try docstring metadata - # This works for all documented symbols (constants, types, functions, etc.) - binding = Base.Docs.Binding(mod, key) - meta = Base.Docs.meta(mod) - if haskey(meta, binding) - docs = meta[binding] - if isa(docs, Base.Docs.MultiDoc) && !isempty(docs.docs) - for (sig, docstr) in docs.docs - if isa(docstr, Base.Docs.DocStr) && haskey(docstr.data, :path) - path = docstr.data[:path] - if path !== nothing && path != "" - return abspath(String(path)) - end - end - end - end - end + # Strategy 1: Try docstring metadata + path = _get_source_from_docstring(mod, key) + path !== nothing && return path obj = getfield(mod, key) - # Strategy 2: For functions and macros, use methods() + # Strategy 2: For functions/macros, use methods() if obj isa Function - m_list = methods(obj) - for m in m_list - file = String(m.file) - if file != "" && file != "none" && !startswith(file, ".") - return abspath(file) - end - end + path = _get_source_from_methods(obj) + path !== nothing && return path end # Strategy 3: For concrete types, try constructor methods if obj isa Type && !isabstracttype(obj) - m_list = methods(obj) - for m in m_list - file = String(m.file) - if file != "" && file != "none" && !startswith(file, ".") - return abspath(file) - end - end - end - - # Strategy 4: For modules - if obj isa Module - # Modules are tricky - we cannot reliably determine their source file - return nothing + path = _get_source_from_methods(obj) + path !== nothing && return path end return nothing catch e - @debug "Could not determine source file for $key in $mod: $e" + @debug "Could not determine source file for $key in $mod" exception=e return nothing end end """ - _exported_symbols(mod) + _get_source_from_docstring(mod::Module, key::Symbol) -> Union{String, Nothing} -Classify all symbols in a module into exported and private categories. +Try to get source file path from docstring metadata. +""" +function _get_source_from_docstring(mod::Module, key::Symbol) + binding = Base.Docs.Binding(mod, key) + meta = Base.Docs.meta(mod) + haskey(meta, binding) || return nothing + + docs = meta[binding] + if isa(docs, Base.Docs.MultiDoc) && !isempty(docs.docs) + for (_, docstr) in docs.docs + if isa(docstr, Base.Docs.DocStr) && haskey(docstr.data, :path) + path = docstr.data[:path] + if path !== nothing && !isempty(path) + return abspath(String(path)) + end + end + end + end + return nothing +end -Inspects the module's public API and internal symbols, filtering out compiler-generated -names and imported symbols. Classifies each symbol by type (function, struct, macro, etc.). +""" + _get_source_from_methods(obj) -> Union{String, Nothing} -# Arguments -- `mod::Module`: Module to analyze +Try to get source file path from method definitions. +""" +function _get_source_from_methods(obj) + for m in methods(obj) + file = String(m.file) + if file != "" && file != "none" && !startswith(file, ".") + return abspath(file) + end + end + return nothing +end + +# ═══════════════════════════════════════════════════════════════════════════════ +# Helper Functions: Symbol Iteration +# ═══════════════════════════════════════════════════════════════════════════════ -# Returns -- `NamedTuple`: With fields `exported` and `private`, each containing sorted lists of `(Symbol, DocType)` pairs """ -function _exported_symbols(mod) - exported = Pair{Symbol,DocType}[] - private = Pair{Symbol,DocType}[] - exported_names = Set(names(mod; all=false)) # Only exported symbols + _iterate_over_symbols(f, config, symbol_list) - # Use all=true, imported=false to include non-exported (private) symbols - # defined in this module, but skip names imported from other modules. - for n in names(mod; all=true, imported=false) - name_str = String(n) - # Skip internal compiler-generated symbols like #save_json##... which - # do not have meaningful bindings for documentation. - if startswith(name_str, "#") +Iterate over symbols, applying a function to each documented symbol. +Filters symbols based on exclusion list, documentation presence, and source files. +""" +function _iterate_over_symbols(f, config::_Config, symbol_list) + current_module = config.current_module + effective_source_files = _get_effective_source_files(config) + + for (key, type) in sort!(copy(symbol_list); by=config.sort_by) + key isa Symbol || continue + + # Check exclusion + key in config.exclude && continue + + # Check documentation + if !_has_documentation(current_module, key, type, config.modules) continue end - # Skip the module itself - if n == nameof(mod) + + # Check source file filtering + if !_passes_source_filter(current_module, key, type, effective_source_files, config.include_without_source) continue end + + f(key, type) + end + return nothing +end - local f - try - f = getfield(mod, n) - catch - continue +""" + _has_documentation(mod::Module, key::Symbol, type::DocType, modules::Dict) -> Bool + +Check if a symbol has documentation. Logs a warning if not. +""" +function _has_documentation(mod::Module, key::Symbol, type::DocType, modules::Dict) + binding = Base.Docs.Binding(mod, key) + + has_doc = if isdefined(Base.Docs, :hasdoc) + Base.Docs.hasdoc(binding) + else + doc = Base.Docs.doc(binding) + doc !== nothing && !occursin("No documentation found.", string(doc)) + end + + if !has_doc + if type == DOCTYPE_MODULE + submod = getfield(mod, key) + if submod != mod && haskey(modules, submod) + return true # Module is documented elsewhere + end end + @warn "No documentation found for $key in $mod. Skipping from API reference." + return false + end + return true +end - doc_type = _classify_symbol(f, name_str) +""" + _passes_source_filter(mod, key, type, source_files, include_without_source) -> Bool - # Separate exported from private - if n in exported_names - push!(exported, n => doc_type) - else - push!(private, n => doc_type) +Check if a symbol passes the source file filter. +""" +function _passes_source_filter(mod::Module, key::Symbol, type::DocType, source_files::Vector{String}, include_without_source::Bool) + isempty(source_files) && return true + + source_path = _get_source_file(mod, key, type) + if source_path === nothing + if !include_without_source + @debug "Cannot determine source file for $key ($type), skipping." + return false end + return true end - - order = Dict( - DOCTYPE_MODULE => 0, - DOCTYPE_MACRO => 1, - DOCTYPE_FUNCTION => 2, - DOCTYPE_ABSTRACT_TYPE => 3, - DOCTYPE_STRUCT => 4, - DOCTYPE_CONSTANT => 5, - ) - sort_fn = x -> (order[x[2]], "$(x[1])") - return (exported=sort(exported; by=sort_fn), private=sort(private; by=sort_fn)) + + return source_path in source_files end +# ═══════════════════════════════════════════════════════════════════════════════ +# Helper Functions: Type Formatting for @docs Blocks +# ═══════════════════════════════════════════════════════════════════════════════ + """ - _iterate_over_symbols(f, config, symbol_list) + _method_signature_string(m::Method, mod::Module, key::Symbol) -> String -Iterate over symbols, applying a function to each documented symbol. +Generate a Documenter-compatible signature string for a method. +Returns a string like `Module.func(::Type1, ::Type2)` for use in `@docs` blocks. +""" +function _method_signature_string(m::Method, mod::Module, key::Symbol) + sig = m.sig + while sig isa UnionAll + sig = sig.body + end -Filters symbols based on: -1. Exclusion list (`config.exclude`) -2. Presence of documentation (warns and skips undocumented symbols) -3. Source file filtering (`config.source_files`) + if !(sig <: Tuple) + return "$(mod).$(key)" + end -# Arguments + params = sig.parameters + arg_types = length(params) > 1 ? params[2:end] : Any[] -- `f::Function`: Callback function `f(key::Symbol, type::DocType)` applied to each valid symbol. -- `config::_Config`: Configuration containing exclusion rules, module info, and source file filters. -- `symbol_list::Vector{Pair{Symbol,DocType}}`: List of symbol-type pairs to process. + if isempty(arg_types) + return "$(mod).$(key)()" + end + + type_strs = [_format_type_for_docs(T) for T in arg_types] + return "$(mod).$(key)($(join(type_strs, ", ")))" +end -# Returns +""" + _format_type_for_docs(T) -> String -- `nothing` +Format a type for use in Documenter's `@docs` block. +Always fully qualifies types to avoid UndefVarError when Documenter evaluates in Main. """ -function _iterate_over_symbols(f, config, symbol_list) - current_module = config.current_module - for (key, type) in sort!(symbol_list; by=config.sort_by) - if key isa Symbol - if key in config.exclude - continue - end - binding = Base.Docs.Binding(current_module, key) - missing_doc = false - if isdefined(Base.Docs, :hasdoc) - missing_doc = !Base.Docs.hasdoc(binding) - else - doc = Base.Docs.doc(binding) - missing_doc = - doc === nothing || occursin("No documentation found.", string(doc)) - end - if missing_doc - if type == DOCTYPE_MODULE - mod = getfield(current_module, key) - if mod == current_module || !haskey(config.modules, mod) - @warn "No documentation found for module $key in $(current_module). Skipping from API reference." - continue - end - else - @warn "No documentation found for $key in $(current_module). Skipping from API reference." - continue - end - end - if !isempty(config.source_files) - source_path = _get_source_file(current_module, key, type) - if source_path === nothing - # If we can't determine source, include only if include_without_source is true - if !config.include_without_source - @debug "Cannot determine source file for $key ($(type)), skipping." - continue - end - else - # Check if source_path matches any allowed file - keep = any(allowed -> source_path == allowed, config.source_files) - if !keep - continue - end - end - end - end - f(key, type) +function _format_type_for_docs(T) + # Vararg + if T isa Core.TypeofVararg + inner = _format_type_for_docs(T.T) + inner_clean = startswith(inner, "::") ? inner[3:end] : inner + return "::Vararg{$(inner_clean)}" end - return nothing + + # TypeVar + T isa TypeVar && return "::$(T.name)" + + # UnionAll - unwrap and format + T isa UnionAll && return _format_type_for_docs(Base.unwrap_unionall(T)) + + # DataType + if T isa DataType + return _format_datatype_for_docs(T) + end + + # Union + if T isa Union + union_types = Base.uniontypes(T) + formatted = [_format_type_for_docs(ut) for ut in union_types] + cleaned = [startswith(s, "::") ? s[3:end] : s for s in formatted] + return "::Union{$(join(cleaned, ", "))}" + end + + return "::$(T)" end """ - _to_string(x::DocType) + _format_datatype_for_docs(T::DataType) -> String -Convert a DocType enumeration value to its string representation. +Format a DataType for use in @docs blocks. +""" +function _format_datatype_for_docs(T::DataType) + type_mod = parentmodule(T) + type_name = T.name.name + is_core_or_base = type_mod === Core || type_mod === Base + + # Handle parametric types + if !isempty(T.parameters) + has_typevar_params = any(p -> p isa TypeVar, T.parameters) + + if has_typevar_params + # Strip type parameters to avoid UndefVarError + return is_core_or_base ? "::$(type_name)" : "::$(type_mod).$(type_name)" + else + # Keep concrete type parameters + params = [_format_type_param(p) for p in T.parameters] + params_str = join(params, ", ") + return is_core_or_base ? "::$(type_name){$(params_str)}" : "::$(type_mod).$(type_name){$(params_str)}" + end + end -# Arguments -- `x::DocType`: Documentation type to convert - -# Returns -- `String`: Human-readable name (e.g., "function", "struct", "macro") -""" -function _to_string(x::DocType) - if x == DOCTYPE_ABSTRACT_TYPE - return "abstract type" - elseif x == DOCTYPE_CONSTANT - return "constant" - elseif x == DOCTYPE_FUNCTION - return "function" - elseif x == DOCTYPE_MACRO - return "macro" - elseif x == DOCTYPE_MODULE - return "module" - elseif x == DOCTYPE_STRUCT - return "struct" + # Simple type + return is_core_or_base ? "::$(type_name)" : "::$(type_mod).$(type_name)" +end + +""" + _format_type_param(p) -> String + +Format a type parameter (can be a type or a value like an integer). +""" +function _format_type_param(p) + if p isa Type + s = _format_type_for_docs(p) + return startswith(s, "::") ? s[3:end] : s + elseif p isa TypeVar + return string(p.name) + else + return string(p) end end +# ═══════════════════════════════════════════════════════════════════════════════ +# Page Building Functions +# ═══════════════════════════════════════════════════════════════════════════════ + """ _build_api_page(document::Documenter.Document, config::_Config) Generate public and/or private API reference pages for a module. +Accumulates content in `PAGE_CONTENT_ACCUMULATOR` for later finalization. +""" +function _build_api_page(document::Documenter.Document, config::_Config) + current_module = config.current_module + symbols = _exported_symbols(current_module) -Creates markdown pages listing symbols with their docstrings. When both -`config.public` and `config.private` are `true`, two separate pages are -generated (`public.md` and `private.md`). Otherwise, a single page is created -using `config.filename`. + # Determine output filenames + public_basename = config.public && config.private ? "public" : config.filename + private_basename = config.public && config.private ? "private" : config.filename + private_filename = _build_page_path(config.subdirectory, "$private_basename.md") -# Arguments + # Collect docstrings + public_docstrings = config.public ? _collect_module_docstrings(config, symbols.exported) : String[] + private_docstrings = config.private ? _collect_private_docstrings(config, symbols.private) : String[] -- `document::Documenter.Document`: The Documenter document object to add pages to. -- `config::_Config`: Configuration specifying the module, output paths, and filtering options. + # Accumulate content + if !haskey(PAGE_CONTENT_ACCUMULATOR, private_filename) + PAGE_CONTENT_ACCUMULATOR[private_filename] = Tuple{Module, Vector{String}, Vector{String}}[] + end + push!(PAGE_CONTENT_ACCUMULATOR[private_filename], (current_module, public_docstrings, private_docstrings)) -# Returns + return nothing +end -- `nothing` """ -function _build_api_page(document::Documenter.Document, config::_Config) - subdir = config.subdirectory + _collect_module_docstrings(config::_Config, symbol_list) -> Vector{String} + +Collect docstring blocks for symbols from the current module. +""" +function _collect_module_docstrings(config::_Config, symbol_list) + docstrings = String[] current_module = config.current_module - symbols = _exported_symbols(current_module) + + _iterate_over_symbols(config, symbol_list) do key, type + type == DOCTYPE_MODULE && return nothing + push!(docstrings, "## `$key`\n\n```@docs\n$(current_module).$key\n```\n\n") + return nothing + end + + return docstrings +end - # Determine the page title: use config.title for single-page cases, - # otherwise use default "Public API" / "Private API" for dual-page cases. - page_title = config.title +""" + _collect_private_docstrings(config::_Config, symbol_list) -> Vector{String} - # Build Public API page - public_title = config.public && config.private ? "Public API" : page_title - public_overview = """ - # $(public_title) +Collect docstring blocks for private symbols, including external module methods. +""" +function _collect_private_docstrings(config::_Config, symbol_list) + docstrings = _collect_module_docstrings(config, symbol_list) + + # Add docstrings from external modules + if !isempty(config.external_modules_to_document) + external_docs = _collect_external_module_docstrings(config) + append!(docstrings, external_docs) + end + + return docstrings +end - This page lists **exported** symbols of `$(current_module)`. +""" + _collect_external_module_docstrings(config::_Config) -> Vector{String} - Load all public symbols into the current scope with: - ```julia - using $(current_module) - ``` - Alternatively, load only the module with: - ```julia - import $(current_module) - ``` - and then prefix all calls with `$(current_module).` to create - `$(current_module).`. - """ - if config.public - # Choose output filename: when both public & private are requested we - # keep `public.md`; otherwise, use the configured `filename` (already - # computed with defaults in automatic_reference_documentation). - public_basename = config.public && config.private ? "public" : config.filename - public_docstrings = String[] - _iterate_over_symbols(config, symbols.exported) do key, type - if type == DOCTYPE_MODULE - return nothing +Collect docstrings for methods from external modules defined in source files. +""" +function _collect_external_module_docstrings(config::_Config) + docstrings = String[] + added_signatures = Set{String}() + filtered_source_files = _get_effective_source_files(config) + + for extra_mod in config.external_modules_to_document + methods_by_func = _collect_methods_from_source_files(extra_mod, filtered_source_files) + + for (key, method_list) in sort(collect(methods_by_func); by=first) + for m in method_list + sig_str = _method_signature_string(m, extra_mod, key) + sig_str in added_signatures && continue + + push!(added_signatures, sig_str) + push!(docstrings, "## `$(extra_mod).$key`\n\n```@docs\n$(sig_str)\n```\n\n") end - push!( - public_docstrings, "## `$key`\n\n```@docs\n$(current_module).$key\n```\n\n" - ) - return nothing end - public_md = Markdown.parse(public_overview * join(public_docstrings, "\n")) - public_filename = _build_page_path(subdir, "$public_basename.md") - document.blueprint.pages[public_filename] = Documenter.Page( - joinpath(document.user.source, public_filename), - joinpath(document.user.build, public_filename), + end + + return docstrings +end + +""" + _collect_methods_from_source_files(mod::Module, source_files::Vector{String}) -> Dict{Symbol, Vector{Method}} + +Collect all methods from a module that are defined in the given source files. +""" +function _collect_methods_from_source_files(mod::Module, source_files::Vector{String}) + methods_by_func = Dict{Symbol, Vector{Method}}() + + for key in names(mod; all=true) + obj = try + getfield(mod, key) + catch + continue + end + + obj isa Function || continue + + for m in methods(obj) + file = String(m.file) + (file == "" || file == "none") && continue + + abs_file = abspath(file) + should_include = isempty(source_files) || (abs_file in source_files) + + if should_include + if !haskey(methods_by_func, key) + methods_by_func[key] = Method[] + end + push!(methods_by_func[key], m) + end + end + end + + return methods_by_func +end + +""" + _finalize_api_pages(document::Documenter.Document) + +Finalize all accumulated API pages by combining content from multiple modules. +""" +function _finalize_api_pages(document::Documenter.Document) + for (filename, module_contents) in PAGE_CONTENT_ACCUMULATOR + is_private = occursin("private", filename) || !occursin("public", filename) + + all_modules = [mc[1] for mc in module_contents] + modules_str = join([string(m) for m in all_modules], "`, `") + + overview, all_docstrings = if is_private + _build_private_page_content(modules_str, module_contents) + else + _build_public_page_content(modules_str, module_contents) + end + + combined_md = Markdown.parse(overview * join(all_docstrings, "\n")) + + document.blueprint.pages[filename] = Documenter.Page( + joinpath(document.user.source, filename), + joinpath(document.user.build, filename), document.user.build, - public_md.content, + combined_md.content, Documenter.Globals(), - convert(MarkdownAST.Node, public_md), + convert(MarkdownAST.Node, combined_md), ) end + + empty!(PAGE_CONTENT_ACCUMULATOR) + return nothing +end - # Build Private API page - private_title = config.public && config.private ? "Private API" : page_title - private_overview = """ +""" + _build_private_page_content(modules_str, module_contents) -> Tuple{String, Vector{String}} + +Build the overview and docstrings for a private API page. +""" +function _build_private_page_content(modules_str::String, module_contents) + overview = """ ```@meta EditURL = nothing ``` - # $(private_title) + # Private API - This page lists **non-exported** (internal) symbols of `$(current_module)`. + This page lists **non-exported** (internal) symbols of `$(modules_str)`. - Access these symbols with: - ```julia - import $(current_module) - $(current_module). - ``` """ - if config.private - # Choose output filename: when both public & private are requested we - # keep `private.md`; otherwise, use the configured `filename` (already - # computed with defaults in automatic_reference_documentation). - private_basename = config.public && config.private ? "private" : config.filename - private_docstrings = String[] - _iterate_over_symbols(config, symbols.private) do key, type - if type == DOCTYPE_MODULE - return nothing - end - push!( - private_docstrings, "## `$key`\n\n```@docs\n$(current_module).$key\n```\n\n" - ) - return nothing - end - # Add docstrings from additional modules (e.g., Base.showerror) - # For each extra module, find functions that have methods defined in source_files - for extra_mod in config.doc_modules - found_symbols = Set{Symbol}() - for key in names(extra_mod; all=true) - key in found_symbols && continue - try - obj = getfield(extra_mod, key) - if obj isa Function - # Check if any method of this function is defined in source_files - for m in methods(obj) - file = String(m.file) - if file != "" && file != "none" - abs_file = abspath(file) - if abs_file in config.source_files - push!(found_symbols, key) - push!( - private_docstrings, - "## `$(extra_mod).$key`\n\n```@docs\n$(extra_mod).$key\n```\n\n", - ) - break - end - end - end - end - catch - continue - end - end + + all_docstrings = String[] + for (mod, _, private_docs) in module_contents + if !isempty(private_docs) + push!(all_docstrings, "\n---\n\n### From `$(mod)`\n\n") + append!(all_docstrings, private_docs) end - private_md = Markdown.parse(private_overview * join(private_docstrings, "\n")) - private_filename = _build_page_path(subdir, "$private_basename.md") - document.blueprint.pages[private_filename] = Documenter.Page( - joinpath(document.user.source, private_filename), - joinpath(document.user.build, private_filename), - document.user.build, - private_md.content, - Documenter.Globals(), - convert(MarkdownAST.Node, private_md), - ) end - - return nothing + + return overview, all_docstrings end """ - Documenter.Selectors.runner(::Type{APIBuilder}, document) + _build_public_page_content(modules_str, module_contents) -> Tuple{String, Vector{String}} -Documenter pipeline runner for API reference generation. +Build the overview and docstrings for a public API page. +""" +function _build_public_page_content(modules_str::String, module_contents) + overview = """ + # Public API -Processes all registered module configurations and generates their API reference pages. -This function is called automatically by Documenter during the documentation build. + This page lists **exported** symbols of `$(modules_str)`. -# Arguments -- `document::Documenter.Document`: Documenter document object -""" -function Documenter.Selectors.runner(::Type{APIBuilder}, document::Documenter.Document) - @info "APIBuilder: creating API reference" - for config in CONFIG - _build_api_page(document, config) + """ + + all_docstrings = String[] + for (mod, public_docs, _) in module_contents + if !isempty(public_docs) + push!(all_docstrings, "\n---\n\n### From `$(mod)`\n\n") + append!(all_docstrings, public_docs) + end end - return nothing + + return overview, all_docstrings end end # module From 2f3a0e673f36181275ddacee991e22c3b6d39923 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Mon, 8 Dec 2025 12:24:42 +0100 Subject: [PATCH 2/3] fix tests --- test/test_documenter_reference.jl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_documenter_reference.jl b/test/test_documenter_reference.jl index 12f3e4dd..cad3ea10 100644 --- a/test/test_documenter_reference.jl +++ b/test/test_documenter_reference.jl @@ -211,7 +211,7 @@ function test_documenter_reference() pages1 = CTBase.automatic_reference_documentation( CTBase.DocumenterReferenceTag(); subdirectory="ref", - modules=[DocumenterReferenceTestMod], + primary_modules=[DocumenterReferenceTestMod], public=true, private=false, title="My API", @@ -231,7 +231,7 @@ function test_documenter_reference() pages2 = CTBase.automatic_reference_documentation( CTBase.DocumenterReferenceTag(); subdirectory="ref", - modules=[DocumenterReferenceTestMod], + primary_modules=[DocumenterReferenceTestMod], public=true, private=true, title="All API", @@ -250,7 +250,7 @@ function test_documenter_reference() @test_throws ErrorException CTBase.automatic_reference_documentation( CTBase.DocumenterReferenceTag(); subdirectory="ref", - modules=[DocumenterReferenceTestMod], + primary_modules=[DocumenterReferenceTestMod], public=false, private=false, ) @@ -306,7 +306,7 @@ function test_documenter_reference() pages = CTBase.automatic_reference_documentation( CTBase.DocumenterReferenceTag(); subdirectory="api", - modules=[mod1, mod1], # Two entries to trigger multi-module path + primary_modules=[mod1, mod1], # Two entries to trigger multi-module path public=true, private=true, title="Multi API", @@ -317,8 +317,8 @@ function test_documenter_reference() @test first(pages) == "Multi API" @test last(pages) isa Vector - # CONFIG should have 2 entries (one per module) - @test length(DR.CONFIG) == 2 + # CONFIG should have 1 entry (one per unique module) + @test length(DR.CONFIG) == 1 end # ============================================================================ From 51536ab3f62b52657dbb389ff0554c5d6fea3244 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Mon, 8 Dec 2025 12:43:46 +0100 Subject: [PATCH 3/3] new integration tests --- test/test_documenter_reference.jl | 152 ++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/test/test_documenter_reference.jl b/test/test_documenter_reference.jl index cad3ea10..87681bed 100644 --- a/test/test_documenter_reference.jl +++ b/test/test_documenter_reference.jl @@ -34,6 +34,24 @@ const MYCONST = 42 end +module DRTypeFormatTestMod + struct Simple end + struct Parametric{T} end + struct WithValue{T,N} end +end + +module DRMethodTestMod + f() = 1 + f(x::Int) = x + g(x::Int, y::String) = x + h(xs::Int...) = length(xs) +end + +module DRExternalTestMod + extfun(x::Int) = x + extfun(x::String) = length(x) +end + using .DocumenterReferenceTestMod function test_documenter_reference() @@ -413,4 +431,138 @@ function test_documenter_reference() DR.reset_config!() @test isempty(DR.CONFIG) end + + @testset "_format_type_for_docs and helpers" begin + simple_str = DR._format_type_for_docs(DRTypeFormatTestMod.Simple) + @test startswith(simple_str, "::") + @test occursin("DRTypeFormatTestMod.Simple", simple_str) + + param_type = DRTypeFormatTestMod.Parametric{DRTypeFormatTestMod.Simple} + param_str = DR._format_type_for_docs(param_type) + @test occursin("Parametric", param_str) + @test occursin("Simple", param_str) + + union_str = DR._format_type_for_docs(Union{DRTypeFormatTestMod.Simple, Nothing}) + @test occursin("Union", union_str) + @test occursin("Simple", union_str) + @test occursin("Nothing", union_str) + + value_type = DRTypeFormatTestMod.WithValue{Int, 3} + value_str = DR._format_type_for_docs(value_type) + @test occursin("WithValue", value_str) + @test occursin("3", value_str) + + param_fmt = DR._format_type_param(DRTypeFormatTestMod.Simple) + @test occursin("DRTypeFormatTestMod.Simple", param_fmt) + + value_param_fmt = DR._format_type_param(3) + @test value_param_fmt == "3" + end + + @testset "_method_signature_string and method collection" begin + methods_f = methods(DRMethodTestMod.f) + m0 = first(filter(m -> length(m.sig.parameters) == 1, methods_f)) + sig0 = DR._method_signature_string(m0, DRMethodTestMod, :f) + @test endswith(sig0, "DRMethodTestMod.f()") + + m1 = first(filter(m -> length(m.sig.parameters) == 2, methods_f)) + sig1 = DR._method_signature_string(m1, DRMethodTestMod, :f) + @test occursin("DRMethodTestMod.f", sig1) + @test occursin("Int", sig1) + + methods_h = methods(DRMethodTestMod.h) + mvar = first(methods_h) + sigv = DR._method_signature_string(mvar, DRMethodTestMod, :h) + @test occursin("Vararg", sigv) + + source_files = [abspath(@__FILE__)] + methods_by_func = DR._collect_methods_from_source_files(DRMethodTestMod, source_files) + @test :f in keys(methods_by_func) + @test :h in keys(methods_by_func) + for ms in values(methods_by_func) + for m in ms + file = String(m.file) + @test abspath(file) in source_files + end + end + end + + @testset "Page content builders" begin + modules_str = "ModA, ModB" + module_contents_private = [ + (DocumenterReferenceTestMod, String[], ["priv_a"]), + (DRMethodTestMod, String[], ["priv_b1", "priv_b2"]), + ] + overview_priv, docs_priv = DR._build_private_page_content(modules_str, module_contents_private) + @test occursin("Private API", overview_priv) + @test occursin("ModA, ModB", overview_priv) + @test !isempty(docs_priv) + @test any(occursin("priv_a", s) for s in docs_priv) + @test any(occursin("priv_b1", s) for s in docs_priv) + + module_contents_public = [ + (DocumenterReferenceTestMod, ["pub_a"], String[]), + (DRMethodTestMod, String[], String[]), + ] + overview_pub, docs_pub = DR._build_public_page_content(modules_str, module_contents_public) + @test occursin("Public API", overview_pub) + @test occursin("ModA, ModB", overview_pub) + @test !isempty(docs_pub) + @test any(occursin("pub_a", s) for s in docs_pub) + end + + @testset "external_modules_to_document" begin + current_module = DocumenterReferenceTestMod + modules = Dict(current_module => String[]) + sort_by(x) = x + source_files = [abspath(@__FILE__)] + + config = DR._Config( + current_module, + "api_ext", + modules, + sort_by, + Set{Symbol}(), + true, + true, + "Ext API", + "Ext API", + source_files, + "api_ext", + false, + [DRExternalTestMod], + ) + + private_docs = DR._collect_private_docstrings(config, Pair{Symbol,DR.DocType}[]) + @test !isempty(private_docs) + @test any(occursin("DRExternalTestMod.extfun", s) for s in private_docs) + end + + @testset "APIBuilder runner integration" begin + DR.reset_config!() + + pages = CTBase.automatic_reference_documentation( + CTBase.DocumenterReferenceTag(); + subdirectory="api_integration", + primary_modules=[DocumenterReferenceTestMod], + public=true, + private=true, + title="Integration API", + ) + + @test !isempty(DR.CONFIG) + + doc = Documenter.Document( + ; + root=pwd(), + source="src", + build="build", + remotes=nothing, + ) + + Documenter.Selectors.runner(DR.APIBuilder, doc) + + @test !isempty(doc.blueprint.pages) + @test any(endswith(k, "private.md") for k in keys(doc.blueprint.pages)) + end end