Skip to main content

sphinx_ultra/
python_config.rs

1use anyhow::{anyhow, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6use crate::config::BuildConfig;
7
8/// Python configuration parser for conf.py files.
9///
10/// This is a *parser*, not an executor: it handles the declarative subset of
11/// Python used by typical conf.py files (assignments of literals, including
12/// multi-line lists/dicts/tuples, string concatenation, and triple-quoted
13/// strings). Every construct it cannot handle produces a [`ConfigWarning`] —
14/// silent dropping is banned. Full execution arrives with the Python sidecar
15/// (ROADMAP M5).
16pub struct PythonConfigParser {
17    conf_namespace: HashMap<String, serde_json::Value>,
18    warnings: Vec<ConfigWarning>,
19}
20
21/// A conf.py construct that could not be parsed and was dropped.
22#[derive(Debug, Clone)]
23pub struct ConfigWarning {
24    /// 1-based line in conf.py where the construct starts.
25    pub line: usize,
26    pub message: String,
27}
28
29/// Represents a parsed conf.py configuration
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ConfPyConfig {
32    // Project information
33    pub project: Option<String>,
34    pub version: Option<String>,
35    pub release: Option<String>,
36    pub copyright: Option<String>,
37    pub author: Option<String>,
38
39    // General configuration
40    pub extensions: Vec<String>,
41    pub templates_path: Vec<String>,
42    pub exclude_patterns: Vec<String>,
43    pub include_patterns: Vec<String>,
44    pub source_suffix: HashMap<String, String>,
45    pub root_doc: Option<String>,
46    pub language: Option<String>,
47    pub locale_dirs: Vec<String>,
48    pub gettext_compact: Option<bool>,
49
50    // HTML output options
51    pub html_theme: Option<String>,
52    pub html_theme_options: HashMap<String, serde_json::Value>,
53    pub html_title: Option<String>,
54    pub html_short_title: Option<String>,
55    pub html_logo: Option<String>,
56    pub html_favicon: Option<String>,
57    pub html_css_files: Vec<String>,
58    pub html_js_files: Vec<String>,
59    pub html_static_path: Vec<String>,
60    pub html_extra_path: Vec<String>,
61    pub html_use_index: Option<bool>,
62    pub html_split_index: Option<bool>,
63    pub html_copy_source: Option<bool>,
64    pub html_show_sourcelink: Option<bool>,
65    pub html_sourcelink_suffix: Option<String>,
66    pub html_use_opensearch: Option<String>,
67    pub html_file_suffix: Option<String>,
68    pub html_link_suffix: Option<String>,
69    pub html_show_copyright: Option<bool>,
70    pub html_show_sphinx: Option<bool>,
71    pub html_context: HashMap<String, serde_json::Value>,
72    pub html_output_encoding: Option<String>,
73    pub html_compact_lists: Option<bool>,
74    pub html_secnumber_suffix: Option<String>,
75    pub html_search_language: Option<String>,
76    pub html_search_options: HashMap<String, serde_json::Value>,
77    pub html_search_scorer: Option<String>,
78    pub html_scaled_image_link: Option<bool>,
79    pub html_baseurl: Option<String>,
80    pub html_codeblock_linenos_style: Option<String>,
81    pub html_math_renderer: Option<String>,
82    pub html_math_renderer_options: HashMap<String, serde_json::Value>,
83
84    // LaTeX output options
85    pub latex_engine: Option<String>,
86    pub latex_documents: Vec<(String, String, String, String, String)>,
87    pub latex_logo: Option<String>,
88    pub latex_appendices: Vec<String>,
89    pub latex_domain_indices: Option<bool>,
90    pub latex_show_pagerefs: Option<bool>,
91    pub latex_show_urls: Option<String>,
92    pub latex_use_latex_multicolumn: Option<bool>,
93    pub latex_use_xindy: Option<bool>,
94    pub latex_toplevel_sectioning: Option<String>,
95    pub latex_docclass: HashMap<String, String>,
96    pub latex_additional_files: Vec<String>,
97    pub latex_elements: HashMap<String, String>,
98
99    // ePub output options
100    pub epub_title: Option<String>,
101    pub epub_author: Option<String>,
102    pub epub_language: Option<String>,
103    pub epub_publisher: Option<String>,
104    pub epub_copyright: Option<String>,
105    pub epub_identifier: Option<String>,
106    pub epub_scheme: Option<String>,
107    pub epub_uid: Option<String>,
108    pub epub_cover: Option<(String, String)>,
109    pub epub_css_files: Vec<String>,
110    pub epub_pre_files: Vec<(String, String)>,
111    pub epub_post_files: Vec<(String, String)>,
112    pub epub_exclude_files: Vec<String>,
113    pub epub_tocdepth: Option<i32>,
114    pub epub_tocdup: Option<bool>,
115    pub epub_tocscope: Option<String>,
116    pub epub_fix_images: Option<bool>,
117    pub epub_max_image_width: Option<i32>,
118    pub epub_show_urls: Option<String>,
119    pub epub_use_index: Option<bool>,
120    pub epub_description: Option<String>,
121    pub epub_contributor: Option<String>,
122    pub epub_writing_mode: Option<String>,
123
124    // Extension-specific configurations
125    pub extension_configs: HashMap<String, HashMap<String, serde_json::Value>>,
126
127    // Build options
128    pub needs_sphinx: Option<String>,
129    pub needs_extensions: HashMap<String, String>,
130    pub manpages_url: Option<String>,
131    pub nitpicky: Option<bool>,
132    pub nitpick_ignore: Vec<(String, String)>,
133    pub nitpick_ignore_regex: Vec<(String, String)>,
134    pub numfig: Option<bool>,
135    pub numfig_format: HashMap<String, String>,
136    pub numfig_secnum_depth: Option<i32>,
137    pub math_number_all: Option<bool>,
138    pub math_eqref_format: Option<String>,
139    pub math_numfig: Option<bool>,
140    pub tls_verify: Option<bool>,
141    pub tls_cacerts: Option<crate::intersphinx::TlsCacerts>,
142    pub user_agent: Option<String>,
143
144    // Object-signature / py-domain family. `None` means "conf.py did not
145    // mention it", which is what keeps sphinx's own default in place —
146    // notably distinct from `Some(0)` for the two line-length keys, where
147    // the difference decides `PySigConfig::max_len`.
148    pub maximum_signature_line_length: Option<i64>,
149    pub python_maximum_signature_line_length: Option<i64>,
150    pub python_trailing_comma_in_multi_line_signatures: Option<bool>,
151    pub python_display_short_literal_types: Option<bool>,
152    pub python_use_unqualified_type_names: Option<bool>,
153    pub toc_object_entries: Option<bool>,
154    pub toc_object_entries_show_parents: Option<String>,
155    /// `source_encoding` as written, `None` when conf.py said nothing.
156    pub source_encoding: Option<String>,
157    /// `(key, python type name)` for the `int | None` keys whose conf.py
158    /// value is neither an int nor `None` — what sphinx's
159    /// `check_confval_types` warns about (see
160    /// [`crate::config::BuildConfig::confval_type_mismatches`]).
161    pub confval_type_mismatches: Vec<(String, String)>,
162    pub add_function_parentheses: Option<bool>,
163    pub add_module_names: Option<bool>,
164    pub strip_signature_backslash: Option<bool>,
165    pub modindex_common_prefix: Vec<String>,
166
167    // intersphinx
168    /// The raw `intersphinx_mapping` value, exactly as `conf.py` wrote it.
169    /// Normalisation and validation happen in [`ConfPyConfig::to_build_config`],
170    /// where a failure can abort configuration the way Sphinx's `ConfigError`
171    /// aborts the build.
172    pub intersphinx_mapping: serde_json::Value,
173    pub intersphinx_disabled_reftypes: Option<Vec<String>>,
174    pub intersphinx_resolve_self: Option<String>,
175    pub intersphinx_cache_limit: Option<i64>,
176    pub intersphinx_timeout: Option<f64>,
177
178    // Internationalization
179    pub gettext_uuid: Option<bool>,
180    pub gettext_location: Option<bool>,
181    pub gettext_auto_build: Option<bool>,
182    pub gettext_additional_targets: Vec<String>,
183
184    // Custom configurations (catch-all for extension-specific or custom settings)
185    pub custom_configs: HashMap<String, serde_json::Value>,
186
187    /// Every assignment the conf.py made, as parsed: the raw namespace the
188    /// typed fields above are extracted from. Carried into
189    /// [`BuildConfig::conf_values`].
190    pub raw_values: BTreeMap<String, serde_json::Value>,
191}
192
193impl PythonConfigParser {
194    /// Create a new Python configuration parser
195    pub fn new() -> Result<Self> {
196        Ok(Self {
197            conf_namespace: HashMap::new(),
198            warnings: Vec::new(),
199        })
200    }
201
202    /// Constructs dropped during the last parse (never silently discarded).
203    pub fn warnings(&self) -> &[ConfigWarning] {
204        &self.warnings
205    }
206
207    /// Parse a conf.py file and extract configuration
208    pub fn parse_conf_py<P: AsRef<Path>>(&mut self, conf_py_path: P) -> Result<ConfPyConfig> {
209        let conf_py_path = conf_py_path.as_ref();
210        let _conf_dir = conf_py_path
211            .parent()
212            .ok_or_else(|| anyhow!("Invalid conf.py path"))?;
213
214        // Read the conf.py file
215        let conf_py_content = std::fs::read_to_string(conf_py_path)?;
216
217        self.parse_statements(&conf_py_content)?;
218
219        // Extract configuration values
220        self.extract_configuration()
221    }
222
223    /// Parse the declarative subset of a conf.py: literal assignments, with a
224    /// warning recorded for every construct that had to be dropped.
225    fn parse_statements(&mut self, content: &str) -> Result<()> {
226        for (line, stmt) in logical_statements(content) {
227            let stmt = stmt.trim();
228            if stmt.is_empty() {
229                continue;
230            }
231
232            // Imports set no configuration values; ignoring them loses nothing.
233            if stmt.starts_with("import ") || stmt.starts_with("from ") {
234                continue;
235            }
236
237            match split_assignment(stmt) {
238                Some((name, value_src)) => match parse_python_literal(value_src) {
239                    Ok(value) => {
240                        self.conf_namespace.insert(name.to_string(), value);
241                    }
242                    Err(reason) => self.warnings.push(ConfigWarning {
243                        line,
244                        message: format!(
245                            "unsupported value for '{}' dropped ({}): {}",
246                            name,
247                            reason,
248                            snippet(value_src)
249                        ),
250                    }),
251                },
252                None => self.warnings.push(ConfigWarning {
253                    line,
254                    message: format!("unsupported statement dropped: {}", snippet(stmt)),
255                }),
256            }
257        }
258
259        Ok(())
260    }
261
262    /// Extract configuration values from the parsed Python namespace
263    fn extract_configuration(&self) -> Result<ConfPyConfig> {
264        let mut config = ConfPyConfig::default();
265
266        // Helper function to extract optional string values
267        let extract_string = |key: &str| -> Option<String> {
268            self.conf_namespace
269                .get(key)
270                .and_then(|val| val.as_str().map(|s| s.to_string()))
271        };
272
273        // Helper function to extract optional bool values
274        let extract_bool = |key: &str| -> Option<bool> {
275            self.conf_namespace.get(key).and_then(|val| val.as_bool())
276        };
277
278        // Helper function to extract optional int values
279        let extract_int = |key: &str| -> Option<i32> {
280            self.conf_namespace
281                .get(key)
282                .and_then(|val| val.as_i64().map(|i| i as i32))
283        };
284
285        // Helper function to extract list of strings
286        let extract_string_list = |key: &str| -> Vec<String> {
287            self.conf_namespace
288                .get(key)
289                .and_then(|val| val.as_array())
290                .map(|arr| {
291                    arr.iter()
292                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
293                        .collect()
294                })
295                .unwrap_or_default()
296        };
297
298        // Helper function to extract a list of 2-string tuples
299        // (`nitpick_ignore`'s `[('py:func', 'foo'), ...]` shape).
300        let extract_pair_list = |key: &str| -> Vec<(String, String)> {
301            self.conf_namespace
302                .get(key)
303                .and_then(|val| val.as_array())
304                .map(|arr| {
305                    arr.iter()
306                        .filter_map(|pair| {
307                            let pair = pair.as_array()?;
308                            let first = pair.first()?.as_str()?;
309                            let second = pair.get(1)?.as_str()?;
310                            Some((first.to_string(), second.to_string()))
311                        })
312                        .collect()
313                })
314                .unwrap_or_default()
315        };
316
317        // Helper function to extract dictionary
318        let extract_dict = |key: &str| -> HashMap<String, serde_json::Value> {
319            self.conf_namespace
320                .get(key)
321                .and_then(|val| val.as_object())
322                .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
323                .unwrap_or_default()
324        };
325
326        // Extract project information
327        config.project = extract_string("project");
328        config.version = extract_string("version");
329        config.release = extract_string("release");
330        config.copyright = extract_string("copyright");
331        config.author = extract_string("author");
332
333        // Extract general configuration
334        config.extensions = extract_string_list("extensions");
335        config.templates_path = extract_string_list("templates_path");
336        config.exclude_patterns = extract_string_list("exclude_patterns");
337        config.include_patterns = extract_string_list("include_patterns");
338        config.root_doc = extract_string("root_doc").or_else(|| extract_string("master_doc"));
339        config.language = extract_string("language");
340        config.locale_dirs = extract_string_list("locale_dirs");
341        config.gettext_compact = extract_bool("gettext_compact");
342
343        // Extract HTML output options
344        config.html_theme = extract_string("html_theme");
345        config.html_theme_options = extract_dict("html_theme_options");
346        config.html_title = extract_string("html_title");
347        config.html_short_title = extract_string("html_short_title");
348        config.html_logo = extract_string("html_logo");
349        config.html_favicon = extract_string("html_favicon");
350        config.html_css_files = extract_string_list("html_css_files");
351        config.html_js_files = extract_string_list("html_js_files");
352        config.html_static_path = extract_string_list("html_static_path");
353        config.html_extra_path = extract_string_list("html_extra_path");
354        config.html_use_index = extract_bool("html_use_index");
355        config.html_split_index = extract_bool("html_split_index");
356        config.html_copy_source = extract_bool("html_copy_source");
357        config.html_show_sourcelink = extract_bool("html_show_sourcelink");
358        config.html_sourcelink_suffix = extract_string("html_sourcelink_suffix");
359        config.html_use_opensearch = extract_string("html_use_opensearch");
360        config.html_file_suffix = extract_string("html_file_suffix");
361        config.html_link_suffix = extract_string("html_link_suffix");
362        config.html_show_copyright = extract_bool("html_show_copyright");
363        config.html_show_sphinx = extract_bool("html_show_sphinx");
364        config.html_context = extract_dict("html_context");
365        config.html_output_encoding = extract_string("html_output_encoding");
366        config.html_compact_lists = extract_bool("html_compact_lists");
367        config.html_secnumber_suffix = extract_string("html_secnumber_suffix");
368        config.html_search_language = extract_string("html_search_language");
369        config.html_search_options = extract_dict("html_search_options");
370        config.html_search_scorer = extract_string("html_search_scorer");
371        config.html_scaled_image_link = extract_bool("html_scaled_image_link");
372        config.html_baseurl = extract_string("html_baseurl");
373        config.html_codeblock_linenos_style = extract_string("html_codeblock_linenos_style");
374        config.html_math_renderer = extract_string("html_math_renderer");
375        config.html_math_renderer_options = extract_dict("html_math_renderer_options");
376
377        // Extract build options
378        config.needs_sphinx = extract_string("needs_sphinx");
379        config.nitpicky = extract_bool("nitpicky");
380        config.nitpick_ignore = extract_pair_list("nitpick_ignore");
381        config.nitpick_ignore_regex = extract_pair_list("nitpick_ignore_regex");
382        config.numfig = extract_bool("numfig");
383        // `numfig_format` is a str -> str dict; a non-string value is not a
384        // format string sphinx could interpolate, so it is dropped here
385        // rather than carried as JSON.
386        config.numfig_format = extract_dict("numfig_format")
387            .into_iter()
388            .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
389            .collect();
390        config.numfig_secnum_depth = extract_int("numfig_secnum_depth");
391        config.math_number_all = extract_bool("math_number_all");
392        config.math_eqref_format = extract_string("math_eqref_format");
393        config.math_numfig = extract_bool("math_numfig");
394        config.tls_verify = extract_bool("tls_verify");
395        // `tls_cacerts` is `str | dict[str, str] | None` (`config.py:287`):
396        // one CA bundle for everything, or one per host.
397        config.tls_cacerts = self
398            .conf_namespace
399            .get("tls_cacerts")
400            .and_then(|value| match value {
401                serde_json::Value::String(path) => {
402                    Some(crate::intersphinx::TlsCacerts::Bundle(path.clone()))
403                }
404                serde_json::Value::Object(map) => Some(crate::intersphinx::TlsCacerts::PerHost(
405                    map.iter()
406                        .filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_string())))
407                        .collect(),
408                )),
409                _ => None,
410            });
411        config.user_agent = extract_string("user_agent");
412
413        // Object-signature / py-domain family. The two line-length keys are
414        // `int | None`, so they read through `as_i64` rather than
415        // `extract_int`: `x = None` in conf.py is a JSON null, which lands
416        // as `None` exactly like an absent key, and `x = 0` stays `Some(0)`.
417        // Any other type is what sphinx's `check_confval_types` warns about
418        // (`The config value ... has type `str'; expected `NoneType' or
419        // `int'.`) — recorded with the python type name and left unset.
420        let mut mismatches: Vec<(String, String)> = Vec::new();
421        let mut extract_none_default_int = |key: &str| -> Option<i64> {
422            match self.conf_namespace.get(key) {
423                None | Some(serde_json::Value::Null) => None,
424                Some(value) => match value.as_i64() {
425                    Some(int) => Some(int),
426                    None => {
427                        mismatches.push((key.to_string(), python_type_name(value).to_string()));
428                        None
429                    }
430                },
431            }
432        };
433        config.maximum_signature_line_length =
434            extract_none_default_int("maximum_signature_line_length");
435        config.python_maximum_signature_line_length =
436            extract_none_default_int("python_maximum_signature_line_length");
437        config.confval_type_mismatches = mismatches;
438        config.source_encoding = extract_string("source_encoding");
439        config.python_trailing_comma_in_multi_line_signatures =
440            extract_bool("python_trailing_comma_in_multi_line_signatures");
441        config.python_display_short_literal_types =
442            extract_bool("python_display_short_literal_types");
443        config.python_use_unqualified_type_names =
444            extract_bool("python_use_unqualified_type_names");
445        config.toc_object_entries = extract_bool("toc_object_entries");
446        config.toc_object_entries_show_parents = extract_string("toc_object_entries_show_parents");
447        config.add_function_parentheses = extract_bool("add_function_parentheses");
448        config.add_module_names = extract_bool("add_module_names");
449        config.strip_signature_backslash = extract_bool("strip_signature_backslash");
450        config.modindex_common_prefix = extract_string_list("modindex_common_prefix");
451
452        // Extract intersphinx configuration. The mapping is carried raw:
453        // validating it is `to_build_config`'s job, because that is where a
454        // failure can be reported as a configuration error.
455        config.intersphinx_mapping = self
456            .conf_namespace
457            .get("intersphinx_mapping")
458            .cloned()
459            .unwrap_or(serde_json::Value::Null);
460        config.intersphinx_disabled_reftypes = self
461            .conf_namespace
462            .get("intersphinx_disabled_reftypes")
463            .map(|_| extract_string_list("intersphinx_disabled_reftypes"));
464        config.intersphinx_resolve_self = extract_string("intersphinx_resolve_self");
465        config.intersphinx_cache_limit = self
466            .conf_namespace
467            .get("intersphinx_cache_limit")
468            .and_then(serde_json::Value::as_i64);
469        config.intersphinx_timeout = self
470            .conf_namespace
471            .get("intersphinx_timeout")
472            .and_then(serde_json::Value::as_f64);
473
474        // Extract internationalization
475        config.gettext_uuid = extract_bool("gettext_uuid");
476        config.gettext_location = extract_bool("gettext_location");
477        config.gettext_auto_build = extract_bool("gettext_auto_build");
478        config.gettext_additional_targets = extract_string_list("gettext_additional_targets");
479
480        // Extract custom configurations
481        for (key, value) in &self.conf_namespace {
482            if !Self::is_standard_config_key(key) {
483                config.custom_configs.insert(key.clone(), value.clone());
484            }
485        }
486        config.raw_values = self
487            .conf_namespace
488            .iter()
489            .map(|(key, value)| (key.clone(), value.clone()))
490            .collect();
491
492        Ok(config)
493    }
494
495    /// Check if a configuration key is a standard Sphinx configuration
496    fn is_standard_config_key(key: &str) -> bool {
497        matches!(
498            key,
499            "project"
500                | "version"
501                | "release"
502                | "copyright"
503                | "author"
504                | "extensions"
505                | "templates_path"
506                | "exclude_patterns"
507                | "include_patterns"
508                | "source_suffix"
509                | "source_encoding"
510                | "root_doc"
511                | "master_doc"
512                | "language"
513                | "locale_dirs"
514                | "gettext_compact"
515                | "html_theme"
516                | "html_theme_options"
517                | "html_title"
518                | "html_short_title"
519                | "html_logo"
520                | "html_favicon"
521                | "html_css_files"
522                | "html_js_files"
523                | "html_static_path"
524                | "html_extra_path"
525                | "html_use_index"
526                | "html_split_index"
527                | "html_copy_source"
528                | "html_show_sourcelink"
529                | "html_sourcelink_suffix"
530                | "html_use_opensearch"
531                | "html_file_suffix"
532                | "html_link_suffix"
533                | "html_show_copyright"
534                | "html_show_sphinx"
535                | "html_context"
536                | "html_output_encoding"
537                | "html_compact_lists"
538                | "html_secnumber_suffix"
539                | "html_search_language"
540                | "html_search_options"
541                | "html_search_scorer"
542                | "html_scaled_image_link"
543                | "html_baseurl"
544                | "html_codeblock_linenos_style"
545                | "html_math_renderer"
546                | "html_math_renderer_options"
547                | "needs_sphinx"
548                | "nitpicky"
549                | "nitpick_ignore"
550                | "nitpick_ignore_regex"
551                | "maximum_signature_line_length"
552                | "python_maximum_signature_line_length"
553                | "python_trailing_comma_in_multi_line_signatures"
554                | "python_display_short_literal_types"
555                | "python_use_unqualified_type_names"
556                | "toc_object_entries"
557                | "toc_object_entries_show_parents"
558                | "add_function_parentheses"
559                | "add_module_names"
560                | "strip_signature_backslash"
561                | "modindex_common_prefix"
562                | "numfig"
563                | "numfig_format"
564                | "numfig_secnum_depth"
565                | "math_number_all"
566                | "math_eqref_format"
567                | "math_numfig"
568                | "tls_verify"
569                | "tls_cacerts"
570                | "user_agent"
571                | "intersphinx_mapping"
572                | "intersphinx_disabled_reftypes"
573                | "intersphinx_resolve_self"
574                | "intersphinx_cache_limit"
575                | "intersphinx_timeout"
576                | "gettext_uuid"
577                | "gettext_location"
578                | "gettext_auto_build"
579                | "gettext_additional_targets"
580        )
581    }
582}
583
584/// The `type(value).__name__` sphinx's `check_confval_types` prints for a
585/// conf.py literal, by way of its JSON shape. A python tuple arrives as a
586/// list here, so it would be named `list` — a spelling-only difference in
587/// a warning about an already-rejected value.
588fn python_type_name(value: &serde_json::Value) -> &'static str {
589    match value {
590        serde_json::Value::Null => "NoneType",
591        serde_json::Value::Bool(_) => "bool",
592        serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "int",
593        serde_json::Value::Number(_) => "float",
594        serde_json::Value::String(_) => "str",
595        serde_json::Value::Array(_) => "list",
596        serde_json::Value::Object(_) => "dict",
597    }
598}
599
600/// First ~60 chars of a construct, for warning messages.
601fn snippet(s: &str) -> String {
602    let s = s.trim();
603    match s.char_indices().nth(60) {
604        Some((idx, _)) => format!("{}…", &s[..idx]),
605        None => s.to_string(),
606    }
607}
608
609/// Split Python source into logical statements: physical lines joined while
610/// brackets are open, a string (incl. triple-quoted) is unterminated, or a
611/// trailing backslash continues the line. Comments outside strings are
612/// stripped. Yields `(1-based start line, statement text)`.
613fn logical_statements(content: &str) -> Vec<(usize, String)> {
614    let chars: Vec<char> = content.chars().collect();
615    let mut statements = Vec::new();
616
617    let mut buf = String::new();
618    let mut start_line = 1usize;
619    let mut line = 1usize;
620    let mut depth = 0i32;
621    // (quote char, is_triple)
622    let mut string_state: Option<(char, bool)> = None;
623    let mut escaped = false;
624
625    let mut i = 0;
626    while i < chars.len() {
627        let c = chars[i];
628
629        if let Some((quote, triple)) = string_state {
630            buf.push(c);
631            if c == '\n' {
632                line += 1;
633            }
634            if escaped {
635                escaped = false;
636            } else if c == '\\' {
637                escaped = true;
638            } else if c == quote {
639                if triple {
640                    if i + 2 < chars.len() && chars[i + 1] == quote && chars[i + 2] == quote {
641                        buf.push(quote);
642                        buf.push(quote);
643                        i += 2;
644                        string_state = None;
645                    }
646                } else {
647                    string_state = None;
648                }
649            }
650            i += 1;
651            continue;
652        }
653
654        match c {
655            '\'' | '"' => {
656                let triple = i + 2 < chars.len() && chars[i + 1] == c && chars[i + 2] == c;
657                buf.push(c);
658                if triple {
659                    buf.push(c);
660                    buf.push(c);
661                    i += 2;
662                }
663                string_state = Some((c, triple));
664            }
665            '#' => {
666                // Comment: skip to (but not past) end of line.
667                while i + 1 < chars.len() && chars[i + 1] != '\n' {
668                    i += 1;
669                }
670            }
671            '(' | '[' | '{' => {
672                depth += 1;
673                buf.push(c);
674            }
675            ')' | ']' | '}' => {
676                depth -= 1;
677                buf.push(c);
678            }
679            '\\' if i + 1 < chars.len() && chars[i + 1] == '\n' => {
680                // Explicit line continuation: join without the backslash.
681                buf.push(' ');
682                line += 1;
683                i += 1;
684            }
685            '\n' => {
686                line += 1;
687                if depth > 0 {
688                    buf.push('\n');
689                } else {
690                    if !buf.trim().is_empty() {
691                        statements.push((start_line, std::mem::take(&mut buf)));
692                    } else {
693                        buf.clear();
694                    }
695                    start_line = line;
696                }
697            }
698            _ => {
699                if buf.trim().is_empty() && !c.is_whitespace() && buf.is_empty() {
700                    start_line = line;
701                }
702                buf.push(c);
703            }
704        }
705        i += 1;
706    }
707
708    if !buf.trim().is_empty() {
709        statements.push((start_line, buf));
710    }
711
712    statements
713}
714
715/// Split `identifier = <value>` at the first top-level `=` that is a plain
716/// assignment (not `==`, `!=`, `<=`, `>=`, or an augmented assignment).
717/// Returns `None` for anything that is not a simple assignment to a bare name.
718fn split_assignment(stmt: &str) -> Option<(&str, &str)> {
719    let bytes = stmt.as_bytes();
720    let mut depth = 0i32;
721    let mut string_quote: Option<u8> = None;
722
723    for i in 0..bytes.len() {
724        let b = bytes[i];
725        if let Some(q) = string_quote {
726            if b == q && (i == 0 || bytes[i - 1] != b'\\') {
727                string_quote = None;
728            }
729            continue;
730        }
731        match b {
732            b'\'' | b'"' => string_quote = Some(b),
733            b'(' | b'[' | b'{' => depth += 1,
734            b')' | b']' | b'}' => depth -= 1,
735            b'=' if depth == 0 => {
736                let next_eq = bytes.get(i + 1) == Some(&b'=');
737                let prev = if i > 0 { bytes[i - 1] } else { 0 };
738                if next_eq || matches!(prev, b'=' | b'!' | b'<' | b'>') {
739                    return None; // comparison
740                }
741                if matches!(
742                    prev,
743                    b'+' | b'-' | b'*' | b'/' | b'%' | b'&' | b'|' | b'^' | b'@'
744                ) {
745                    return None; // augmented assignment
746                }
747                let name = stmt[..i].trim();
748                let is_identifier = !name.is_empty()
749                    && name
750                        .chars()
751                        .next()
752                        .map(|c| c.is_ascii_alphabetic() || c == '_')
753                        .unwrap_or(false)
754                    && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
755                if !is_identifier {
756                    return None;
757                }
758                return Some((name, stmt[i + 1..].trim()));
759            }
760            _ => {}
761        }
762    }
763    None
764}
765
766/// Recursive-descent parser for Python literals → JSON values.
767/// Supports: strings (escapes, implicit adjacent concatenation, triple
768/// quotes), ints/floats, True/False/None, lists, tuples (as arrays), dicts
769/// with string keys, arbitrary nesting, trailing commas.
770pub(crate) fn parse_python_literal(src: &str) -> std::result::Result<serde_json::Value, String> {
771    let chars: Vec<char> = src.chars().collect();
772    let mut p = PyLiteralParser {
773        chars,
774        pos: 0,
775        saw_comma: false,
776    };
777    let value = p.parse_value()?;
778    p.skip_ws();
779    if p.pos < p.chars.len() {
780        return Err("trailing expression".to_string());
781    }
782    Ok(value)
783}
784
785struct PyLiteralParser {
786    chars: Vec<char>,
787    pos: usize,
788    /// Whether the most recently closed sequence contained a comma — used to
789    /// tell a parenthesized grouping `(x)` from a one-element tuple `(x,)`.
790    saw_comma: bool,
791}
792
793impl PyLiteralParser {
794    fn peek(&self) -> Option<char> {
795        self.chars.get(self.pos).copied()
796    }
797
798    fn skip_ws(&mut self) {
799        while matches!(self.peek(), Some(c) if c.is_whitespace()) {
800            self.pos += 1;
801        }
802    }
803
804    fn parse_value(&mut self) -> std::result::Result<serde_json::Value, String> {
805        self.skip_ws();
806        if let Some(raw) = self.string_prefix() {
807            let mut s = self.parse_string(raw)?;
808            // Implicit adjacent string concatenation: 'a' 'b' == 'ab'
809            while let Some(raw) = {
810                self.skip_ws();
811                self.string_prefix()
812            } {
813                s.push_str(&self.parse_string(raw)?);
814            }
815            return Ok(serde_json::Value::String(s));
816        }
817        match self.peek() {
818            Some('[') => self.parse_sequence('[', ']'),
819            Some('(') => {
820                // Python: `(x)` is grouping, `(x,)` / `(x, y)` is a tuple.
821                // Either way an array (or the inner value) serves config needs.
822                let value = self.parse_sequence('(', ')')?;
823                match value {
824                    serde_json::Value::Array(items) if items.len() == 1 && !self.saw_comma => {
825                        Ok(items.into_iter().next().unwrap())
826                    }
827                    other => Ok(other),
828                }
829            }
830            Some('{') => self.parse_dict(),
831            Some(c) if c.is_ascii_digit() || c == '-' || c == '+' || c == '.' => {
832                self.parse_number()
833            }
834            Some(_) => {
835                if self.eat_keyword("True") {
836                    Ok(serde_json::Value::Bool(true))
837                } else if self.eat_keyword("False") {
838                    Ok(serde_json::Value::Bool(false))
839                } else if self.eat_keyword("None") {
840                    Ok(serde_json::Value::Null)
841                } else {
842                    Err("unsupported expression".to_string())
843                }
844            }
845            None => Err("empty value".to_string()),
846        }
847    }
848
849    fn eat_keyword(&mut self, kw: &str) -> bool {
850        let end = self.pos + kw.len();
851        if end <= self.chars.len() && self.chars[self.pos..end].iter().collect::<String>() == kw {
852            let boundary = self
853                .chars
854                .get(end)
855                .map(|c| !c.is_ascii_alphanumeric() && *c != '_')
856                .unwrap_or(true);
857            if boundary {
858                self.pos = end;
859                return true;
860            }
861        }
862        false
863    }
864
865    /// A string literal starts here — with an optional Python prefix, which
866    /// is consumed. `Some(true)` means the literal is *raw*: `nitpick_ignore_regex`
867    /// entries are conventionally written `r'...'`, and a raw literal's
868    /// backslashes must survive into the pattern. `f` prefixes are
869    /// deliberately not accepted: an f-string's braces are an expression
870    /// this parser cannot evaluate, so it stays an unsupported expression
871    /// (skipped) rather than being taken literally.
872    fn string_prefix(&mut self) -> Option<bool> {
873        if matches!(self.peek(), Some('\'') | Some('"')) {
874            return Some(false);
875        }
876        for len in [2usize, 1] {
877            let quote_at = self.pos + len;
878            if !matches!(self.chars.get(quote_at), Some('\'') | Some('"')) {
879                continue;
880            }
881            let prefix: String = self.chars[self.pos..quote_at]
882                .iter()
883                .collect::<String>()
884                .to_lowercase();
885            if matches!(prefix.as_str(), "r" | "u" | "b" | "rb" | "br") {
886                self.pos = quote_at;
887                return Some(prefix.contains('r'));
888            }
889        }
890        None
891    }
892
893    fn parse_string(&mut self, raw: bool) -> std::result::Result<String, String> {
894        let quote = self.peek().ok_or("expected string")?;
895        self.pos += 1;
896        let triple = self.chars.get(self.pos) == Some(&quote)
897            && self.chars.get(self.pos + 1) == Some(&quote);
898        if triple {
899            self.pos += 2;
900        }
901
902        let mut out = String::new();
903        loop {
904            let c = *self
905                .chars
906                .get(self.pos)
907                .ok_or("unterminated string literal")?;
908            if c == '\\' {
909                let next = *self
910                    .chars
911                    .get(self.pos + 1)
912                    .ok_or("unterminated escape sequence")?;
913                if raw {
914                    // A raw literal keeps both characters — but the escaped
915                    // quote still does not end the string.
916                    out.push('\\');
917                    out.push(next);
918                    self.pos += 2;
919                    continue;
920                }
921                let translated = match next {
922                    'n' => '\n',
923                    't' => '\t',
924                    'r' => '\r',
925                    '\\' => '\\',
926                    '\'' => '\'',
927                    '"' => '"',
928                    other => {
929                        // Unknown escape: Python keeps the backslash.
930                        out.push('\\');
931                        other
932                    }
933                };
934                out.push(translated);
935                self.pos += 2;
936                continue;
937            }
938            if c == quote {
939                if triple {
940                    if self.chars.get(self.pos + 1) == Some(&quote)
941                        && self.chars.get(self.pos + 2) == Some(&quote)
942                    {
943                        self.pos += 3;
944                        return Ok(out);
945                    }
946                } else {
947                    self.pos += 1;
948                    return Ok(out);
949                }
950            }
951            out.push(c);
952            self.pos += 1;
953        }
954    }
955
956    fn parse_number(&mut self) -> std::result::Result<serde_json::Value, String> {
957        let start = self.pos;
958        if matches!(self.peek(), Some('-') | Some('+')) {
959            self.pos += 1;
960        }
961        while matches!(self.peek(), Some(c) if c.is_ascii_digit() || c == '.' || c == '_' || c == 'e' || c == 'E')
962        {
963            self.pos += 1;
964        }
965        let text: String = self.chars[start..self.pos]
966            .iter()
967            .filter(|c| **c != '_')
968            .collect();
969        if let Ok(i) = text.parse::<i64>() {
970            return Ok(serde_json::Value::Number(i.into()));
971        }
972        if let Ok(f) = text.parse::<f64>() {
973            if let Some(n) = serde_json::Number::from_f64(f) {
974                return Ok(serde_json::Value::Number(n));
975            }
976        }
977        Err(format!("invalid number '{text}'"))
978    }
979
980    fn parse_sequence(
981        &mut self,
982        open: char,
983        close: char,
984    ) -> std::result::Result<serde_json::Value, String> {
985        debug_assert_eq!(self.peek(), Some(open));
986        self.pos += 1;
987        self.saw_comma = false;
988        let mut items = Vec::new();
989        let mut saw_comma = false;
990        loop {
991            self.skip_ws();
992            if self.peek() == Some(close) {
993                self.pos += 1;
994                self.saw_comma = saw_comma;
995                return Ok(serde_json::Value::Array(items));
996            }
997            items.push(self.parse_value()?);
998            self.skip_ws();
999            match self.peek() {
1000                Some(',') => {
1001                    saw_comma = true;
1002                    self.pos += 1;
1003                }
1004                Some(c) if c == close => {}
1005                _ => return Err(format!("expected ',' or '{close}'")),
1006            }
1007        }
1008    }
1009
1010    fn parse_dict(&mut self) -> std::result::Result<serde_json::Value, String> {
1011        debug_assert_eq!(self.peek(), Some('{'));
1012        self.pos += 1;
1013        let mut map = serde_json::Map::new();
1014        loop {
1015            self.skip_ws();
1016            if self.peek() == Some('}') {
1017                self.pos += 1;
1018                return Ok(serde_json::Value::Object(map));
1019            }
1020            let key = match self.parse_value()? {
1021                serde_json::Value::String(s) => s,
1022                other => return Err(format!("non-string dict key {other}")),
1023            };
1024            self.skip_ws();
1025            if self.peek() != Some(':') {
1026                return Err("expected ':' in dict".to_string());
1027            }
1028            self.pos += 1;
1029            let value = self.parse_value()?;
1030            map.insert(key, value);
1031            self.skip_ws();
1032            match self.peek() {
1033                Some(',') => {
1034                    self.pos += 1;
1035                }
1036                Some('}') => {}
1037                _ => return Err("expected ',' or '}'".to_string()),
1038            }
1039        }
1040    }
1041}
1042
1043impl Default for ConfPyConfig {
1044    fn default() -> Self {
1045        Self {
1046            project: None,
1047            version: None,
1048            release: None,
1049            copyright: None,
1050            author: None,
1051            extensions: Vec::new(),
1052            templates_path: vec!["_templates".to_string()],
1053            exclude_patterns: Vec::new(),
1054            include_patterns: vec!["**".to_string()], // Sphinx default
1055            source_suffix: HashMap::new(),
1056            root_doc: Some("index".to_string()),
1057            language: None,
1058            locale_dirs: vec!["locales".to_string()],
1059            gettext_compact: Some(true),
1060            html_theme: Some("alabaster".to_string()),
1061            html_theme_options: HashMap::new(),
1062            html_title: None,
1063            html_short_title: None,
1064            html_logo: None,
1065            html_favicon: None,
1066            html_css_files: Vec::new(),
1067            html_js_files: Vec::new(),
1068            html_static_path: vec!["_static".to_string()],
1069            html_extra_path: Vec::new(),
1070            html_use_index: Some(true),
1071            html_split_index: Some(false),
1072            html_copy_source: Some(true),
1073            html_show_sourcelink: Some(true),
1074            html_sourcelink_suffix: Some(".txt".to_string()),
1075            html_use_opensearch: None,
1076            html_file_suffix: Some(".html".to_string()),
1077            html_link_suffix: Some(".html".to_string()),
1078            html_show_copyright: Some(true),
1079            html_show_sphinx: Some(true),
1080            html_context: HashMap::new(),
1081            html_output_encoding: Some("utf-8".to_string()),
1082            html_compact_lists: Some(true),
1083            html_secnumber_suffix: Some(". ".to_string()),
1084            html_search_language: None,
1085            html_search_options: HashMap::new(),
1086            html_search_scorer: None,
1087            html_scaled_image_link: Some(true),
1088            html_baseurl: None,
1089            html_codeblock_linenos_style: Some("table".to_string()),
1090            html_math_renderer: Some("mathjax".to_string()),
1091            html_math_renderer_options: HashMap::new(),
1092            latex_engine: Some("pdflatex".to_string()),
1093            latex_documents: Vec::new(),
1094            latex_logo: None,
1095            latex_appendices: Vec::new(),
1096            latex_domain_indices: Some(true),
1097            latex_show_pagerefs: Some(false),
1098            latex_show_urls: Some("no".to_string()),
1099            latex_use_latex_multicolumn: Some(false),
1100            latex_use_xindy: Some(false),
1101            latex_toplevel_sectioning: None,
1102            latex_docclass: HashMap::new(),
1103            latex_additional_files: Vec::new(),
1104            latex_elements: HashMap::new(),
1105            epub_title: None,
1106            epub_author: None,
1107            epub_language: None,
1108            epub_publisher: None,
1109            epub_copyright: None,
1110            epub_identifier: None,
1111            epub_scheme: None,
1112            epub_uid: None,
1113            epub_cover: None,
1114            epub_css_files: Vec::new(),
1115            epub_pre_files: Vec::new(),
1116            epub_post_files: Vec::new(),
1117            epub_exclude_files: Vec::new(),
1118            epub_tocdepth: Some(3),
1119            epub_tocdup: Some(true),
1120            epub_tocscope: Some("default".to_string()),
1121            epub_fix_images: Some(false),
1122            epub_max_image_width: Some(0),
1123            epub_show_urls: Some("inline".to_string()),
1124            epub_use_index: Some(true),
1125            epub_description: None,
1126            epub_contributor: None,
1127            epub_writing_mode: Some("horizontal".to_string()),
1128            extension_configs: HashMap::new(),
1129            needs_sphinx: None,
1130            needs_extensions: HashMap::new(),
1131            manpages_url: None,
1132            nitpicky: Some(false),
1133            nitpick_ignore: Vec::new(),
1134            nitpick_ignore_regex: Vec::new(),
1135            numfig: Some(false),
1136            numfig_format: HashMap::new(),
1137            numfig_secnum_depth: Some(1),
1138            math_number_all: Some(false),
1139            math_eqref_format: None,
1140            math_numfig: Some(true),
1141            tls_verify: Some(true),
1142            tls_cacerts: None,
1143            user_agent: None,
1144            // `None` = "conf.py said nothing", which leaves
1145            // `BuildConfig::default()`'s sphinx defaults untouched.
1146            maximum_signature_line_length: None,
1147            python_maximum_signature_line_length: None,
1148            python_trailing_comma_in_multi_line_signatures: None,
1149            python_display_short_literal_types: None,
1150            python_use_unqualified_type_names: None,
1151            toc_object_entries: None,
1152            toc_object_entries_show_parents: None,
1153            source_encoding: None,
1154            confval_type_mismatches: Vec::new(),
1155            add_function_parentheses: None,
1156            add_module_names: None,
1157            strip_signature_backslash: None,
1158            modindex_common_prefix: Vec::new(),
1159            intersphinx_mapping: serde_json::Value::Null,
1160            intersphinx_disabled_reftypes: None,
1161            intersphinx_resolve_self: None,
1162            intersphinx_cache_limit: None,
1163            intersphinx_timeout: None,
1164            gettext_uuid: Some(false),
1165            gettext_location: Some(true),
1166            gettext_auto_build: Some(true),
1167            gettext_additional_targets: Vec::new(),
1168            custom_configs: HashMap::new(),
1169            raw_values: BTreeMap::new(),
1170        }
1171    }
1172}
1173
1174impl ConfPyConfig {
1175    /// Convert conf.py configuration to BuildConfig.
1176    ///
1177    /// Fails only where Sphinx itself raises `ConfigError` at
1178    /// `config-inited` — today that is `intersphinx_mapping` validation
1179    /// (`ext/intersphinx/_load.py:131-136`), which aborts the build before
1180    /// it reads a single document.
1181    pub fn to_build_config(&self) -> Result<BuildConfig> {
1182        let mut config = BuildConfig::default();
1183
1184        // Map basic project information
1185        if let Some(project) = &self.project {
1186            config.project = project.clone();
1187        }
1188        if let Some(version) = &self.version {
1189            config.version = Some(version.clone());
1190        }
1191        if let Some(release) = &self.release {
1192            config.release = Some(release.clone());
1193        }
1194        if let Some(copyright) = &self.copyright {
1195            config.copyright = Some(copyright.clone());
1196        }
1197        if let Some(language) = &self.language {
1198            config.language = Some(language.clone());
1199        }
1200        if let Some(root_doc) = &self.root_doc {
1201            config.root_doc = Some(root_doc.clone());
1202        }
1203
1204        // Map extensions
1205        config.extensions = self.extensions.clone();
1206
1207        // Map template paths
1208        config.template_dirs = self.templates_path.iter().map(PathBuf::from).collect();
1209
1210        // Map static paths
1211        config.static_dirs = self.html_static_path.iter().map(PathBuf::from).collect();
1212        config.html_static_path = self.html_static_path.iter().map(PathBuf::from).collect();
1213
1214        // Map HTML configuration
1215        if let Some(html_theme) = &self.html_theme {
1216            config.output.html_theme = html_theme.clone();
1217            config.theme.name = html_theme.clone();
1218        }
1219        if let Some(html_title) = &self.html_title {
1220            config.html_title = Some(html_title.clone());
1221        }
1222        if let Some(html_short_title) = &self.html_short_title {
1223            config.html_short_title = Some(html_short_title.clone());
1224        }
1225        if let Some(html_logo) = &self.html_logo {
1226            config.html_logo = Some(html_logo.clone());
1227        }
1228        if let Some(html_favicon) = &self.html_favicon {
1229            config.html_favicon = Some(html_favicon.clone());
1230        }
1231        config.html_css_files = self.html_css_files.clone();
1232        config.html_js_files = self.html_js_files.clone();
1233        if let Some(html_show_copyright) = self.html_show_copyright {
1234            config.html_show_copyright = Some(html_show_copyright);
1235        }
1236        if let Some(html_show_sphinx) = self.html_show_sphinx {
1237            config.html_show_sphinx = Some(html_show_sphinx);
1238        }
1239        if let Some(html_copy_source) = self.html_copy_source {
1240            config.html_copy_source = Some(html_copy_source);
1241        }
1242        if let Some(html_show_sourcelink) = self.html_show_sourcelink {
1243            config.html_show_sourcelink = Some(html_show_sourcelink);
1244        }
1245        if let Some(html_sourcelink_suffix) = &self.html_sourcelink_suffix {
1246            config.html_sourcelink_suffix = Some(html_sourcelink_suffix.clone());
1247        }
1248        if let Some(html_use_index) = self.html_use_index {
1249            config.html_use_index = Some(html_use_index);
1250        }
1251        if let Some(html_use_opensearch) = &self.html_use_opensearch {
1252            config.html_use_opensearch = Some(!html_use_opensearch.is_empty());
1253        }
1254        if let Some(html_last_updated_fmt) = &self.html_context.get("last_updated") {
1255            if let Some(fmt_str) = html_last_updated_fmt.as_str() {
1256                config.html_last_updated_fmt = Some(fmt_str.to_string());
1257            }
1258        }
1259
1260        // Map templates path
1261        config.templates_path = self.templates_path.iter().map(PathBuf::from).collect();
1262
1263        // Map file patterns (Sphinx compatibility)
1264        config.include_patterns = if self.include_patterns.is_empty() {
1265            vec!["**".to_string()] // Sphinx default
1266        } else {
1267            self.include_patterns.clone()
1268        };
1269        config.exclude_patterns = self.exclude_patterns.clone();
1270
1271        config.nitpicky = self.nitpicky.unwrap_or(false);
1272        config.nitpick_ignore = self.nitpick_ignore.clone();
1273        config.nitpick_ignore_regex = self.nitpick_ignore_regex.clone();
1274        config.html_context = self
1275            .html_context
1276            .iter()
1277            .map(|(key, value)| (key.clone(), value.clone()))
1278            .collect();
1279
1280        // Numbering (`numfig` family). `numfig_format` MERGES over the
1281        // defaults `BuildConfig::default()` seeded — sphinx applies the
1282        // user dict on top of its own at `config-inited` prio 800
1283        // (`config.py:682-693`), so a conf.py naming only `figure` keeps
1284        // `section`/`table`/`code-block`.
1285        config.numfig = self.numfig.unwrap_or(false);
1286        for (figtype, format) in &self.numfig_format {
1287            config.numfig_format.insert(figtype.clone(), format.clone());
1288        }
1289        if let Some(depth) = self.numfig_secnum_depth {
1290            config.numfig_secnum_depth = depth.max(0) as u32;
1291        }
1292
1293        // Object-signature / py-domain family: every key that `conf.py`
1294        // actually named overrides the sphinx default, and nothing else does.
1295        // The two `Option<i64>` keys assign straight through, because for
1296        // them "unset" and "set to None" are the same thing in sphinx too.
1297        config.maximum_signature_line_length = self.maximum_signature_line_length;
1298        config.python_maximum_signature_line_length = self.python_maximum_signature_line_length;
1299        config.confval_type_mismatches = self.confval_type_mismatches.clone();
1300        if let Some(source_encoding) = &self.source_encoding {
1301            config.source_encoding = source_encoding.clone();
1302        }
1303        if let Some(trailing_comma) = self.python_trailing_comma_in_multi_line_signatures {
1304            config.python_trailing_comma_in_multi_line_signatures = trailing_comma;
1305        }
1306        if let Some(short_literals) = self.python_display_short_literal_types {
1307            config.python_display_short_literal_types = short_literals;
1308        }
1309        if let Some(unqualified) = self.python_use_unqualified_type_names {
1310            config.python_use_unqualified_type_names = unqualified;
1311        }
1312        if let Some(toc_object_entries) = self.toc_object_entries {
1313            config.toc_object_entries = toc_object_entries;
1314        }
1315        if let Some(show_parents) = &self.toc_object_entries_show_parents {
1316            // Carried through even when it is outside the ENUM: sphinx only
1317            // warns (`BuildConfig::validate`).
1318            config.toc_object_entries_show_parents = show_parents.clone();
1319        }
1320        if let Some(add_parens) = self.add_function_parentheses {
1321            config.add_function_parentheses = add_parens;
1322        }
1323        if let Some(add_module_names) = self.add_module_names {
1324            config.add_module_names = add_module_names;
1325        }
1326        if let Some(strip_backslash) = self.strip_signature_backslash {
1327            config.strip_signature_backslash = strip_backslash;
1328        }
1329        config.modindex_common_prefix = self.modindex_common_prefix.clone();
1330
1331        // intersphinx + the shared HTTP configuration group.
1332        let (mapping, errors) = crate::intersphinx::validate_mapping(&self.intersphinx_mapping);
1333        for error in &errors {
1334            // Sphinx logs each one with `LOGGER.error` before raising.
1335            log::error!("{error}");
1336        }
1337        if !errors.is_empty() {
1338            return Err(anyhow!(crate::intersphinx::mapping_config_error(
1339                errors.len()
1340            )));
1341        }
1342        config.intersphinx_mapping = mapping;
1343        if let Some(disabled) = &self.intersphinx_disabled_reftypes {
1344            config.intersphinx_disabled_reftypes = disabled.clone();
1345        }
1346        if let Some(resolve_self) = &self.intersphinx_resolve_self {
1347            config.intersphinx_resolve_self = resolve_self.clone();
1348        }
1349        if let Some(limit) = self.intersphinx_cache_limit {
1350            config.intersphinx_cache_limit = limit;
1351        }
1352        config.intersphinx_timeout = self.intersphinx_timeout;
1353        if let Some(tls_verify) = self.tls_verify {
1354            config.tls_verify = tls_verify;
1355        }
1356        config.tls_cacerts = self.tls_cacerts.clone();
1357        config.user_agent = self.user_agent.clone();
1358
1359        config.conf_values = self.raw_values.clone();
1360
1361        Ok(config)
1362    }
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368
1369    fn parse(content: &str) -> PythonConfigParser {
1370        let mut parser = PythonConfigParser::new().unwrap();
1371        parser.parse_statements(content).unwrap();
1372        parser
1373    }
1374
1375    #[test]
1376    fn multiline_list_parses() {
1377        let p = parse("extensions = [\n    'sphinx.ext.autodoc',\n    'sphinx.ext.viewcode',\n]\n");
1378        let v = p.conf_namespace.get("extensions").expect("extensions set");
1379        let items: Vec<&str> = v
1380            .as_array()
1381            .unwrap()
1382            .iter()
1383            .map(|i| i.as_str().unwrap())
1384            .collect();
1385        assert_eq!(items, vec!["sphinx.ext.autodoc", "sphinx.ext.viewcode"]);
1386        assert!(p.warnings().is_empty(), "warnings: {:?}", p.warnings());
1387    }
1388
1389    #[test]
1390    fn multiline_dict_parses() {
1391        let p = parse(
1392            "html_theme_options = {\n    'collapse_navigation': False,\n    'navigation_depth': 4,\n}\n",
1393        );
1394        let v = p
1395            .conf_namespace
1396            .get("html_theme_options")
1397            .expect("dict set");
1398        let obj = v.as_object().unwrap();
1399        assert_eq!(
1400            obj.get("collapse_navigation"),
1401            Some(&serde_json::Value::Bool(false))
1402        );
1403        assert_eq!(
1404            obj.get("navigation_depth").and_then(|n| n.as_i64()),
1405            Some(4)
1406        );
1407    }
1408
1409    #[test]
1410    fn adjacent_string_concat_parses() {
1411        let p = parse("copyright = ('2024, ' 'Team')\n");
1412        assert_eq!(
1413            p.conf_namespace.get("copyright").and_then(|v| v.as_str()),
1414            Some("2024, Team")
1415        );
1416    }
1417
1418    #[test]
1419    fn triple_quoted_string_parses() {
1420        let p = parse("project = \"\"\"Multi\nLine\"\"\"\n");
1421        assert_eq!(
1422            p.conf_namespace.get("project").and_then(|v| v.as_str()),
1423            Some("Multi\nLine")
1424        );
1425    }
1426
1427    #[test]
1428    fn trailing_comment_stripped() {
1429        let p = parse("version = '1.0'  # the version\n");
1430        assert_eq!(
1431            p.conf_namespace.get("version").and_then(|v| v.as_str()),
1432            Some("1.0")
1433        );
1434    }
1435
1436    #[test]
1437    fn unsupported_value_warns_and_drops() {
1438        let p = parse("project = os.environ['P']\n");
1439        assert!(!p.conf_namespace.contains_key("project"));
1440        assert_eq!(p.warnings().len(), 1);
1441        assert_eq!(p.warnings()[0].line, 1);
1442        assert!(
1443            p.warnings()[0].message.contains("project"),
1444            "warning names the variable: {}",
1445            p.warnings()[0].message
1446        );
1447    }
1448
1449    #[test]
1450    fn unsupported_statement_warns_but_imports_do_not() {
1451        let p = parse("import os\nfrom pathlib import Path\nsys.path.insert(0, 'x')\n");
1452        assert_eq!(p.warnings().len(), 1, "warnings: {:?}", p.warnings());
1453        assert_eq!(p.warnings()[0].line, 3);
1454    }
1455
1456    #[test]
1457    fn nested_structures_parse() {
1458        let p = parse(
1459            "intersphinx_mapping = {\n    'python': ('https://docs.python.org/3', None),\n}\n",
1460        );
1461        let v = p.conf_namespace.get("intersphinx_mapping").unwrap();
1462        let python = v
1463            .as_object()
1464            .unwrap()
1465            .get("python")
1466            .unwrap()
1467            .as_array()
1468            .unwrap();
1469        assert_eq!(python[0].as_str(), Some("https://docs.python.org/3"));
1470        assert!(python[1].is_null());
1471    }
1472
1473    #[test]
1474    fn numfig_family_reaches_the_build_config() {
1475        let p = parse(
1476            "numfig = True\nnumfig_secnum_depth = 2\n\
1477             numfig_format = {'figure': 'Figure %s', 'table': 'Table {number}'}\n",
1478        );
1479        let config = p
1480            .extract_configuration()
1481            .unwrap()
1482            .to_build_config()
1483            .unwrap();
1484
1485        assert!(config.numfig);
1486        assert_eq!(config.numfig_secnum_depth, 2);
1487        // The user's two entries merge OVER the four defaults rather than
1488        // replacing them (`config.py:682-693`).
1489        assert_eq!(config.numfig_format["figure"], "Figure %s");
1490        assert_eq!(config.numfig_format["table"], "Table {number}");
1491        assert_eq!(config.numfig_format["section"], "Section %s");
1492        assert_eq!(config.numfig_format["code-block"], "Listing %s");
1493    }
1494
1495    #[test]
1496    fn nitpick_ignore_lists_reach_the_build_config() {
1497        let p = parse(
1498            "nitpicky = True\n\
1499             nitpick_ignore = [('py:func', 'nope'), ('doc', 'missing')]\n\
1500             nitpick_ignore_regex = [(r'std:.*', r'legacy-.*')]\n",
1501        );
1502        let config = p
1503            .extract_configuration()
1504            .unwrap()
1505            .to_build_config()
1506            .unwrap();
1507
1508        assert!(config.nitpicky);
1509        assert_eq!(
1510            config.nitpick_ignore,
1511            vec![
1512                ("py:func".to_string(), "nope".to_string()),
1513                ("doc".to_string(), "missing".to_string()),
1514            ]
1515        );
1516        assert_eq!(
1517            config.nitpick_ignore_regex,
1518            vec![("std:.*".to_string(), "legacy-.*".to_string())]
1519        );
1520    }
1521
1522    /// The eleven object-signature / py-domain keys must reach `BuildConfig`
1523    /// from `conf.py`, with the defaults surviving for anything the file
1524    /// does not mention.
1525    #[test]
1526    fn the_object_signature_family_reaches_the_build_config() {
1527        let p = parse(
1528            "maximum_signature_line_length = 88\n\
1529             python_maximum_signature_line_length = 0\n\
1530             python_trailing_comma_in_multi_line_signatures = False\n\
1531             python_display_short_literal_types = True\n\
1532             python_use_unqualified_type_names = True\n\
1533             toc_object_entries = False\n\
1534             toc_object_entries_show_parents = 'all'\n\
1535             add_function_parentheses = False\n\
1536             add_module_names = False\n\
1537             strip_signature_backslash = True\n\
1538             modindex_common_prefix = ['mypkg.', 'other.']\n",
1539        );
1540        let config = p
1541            .extract_configuration()
1542            .unwrap()
1543            .to_build_config()
1544            .unwrap();
1545
1546        assert_eq!(config.maximum_signature_line_length, Some(88));
1547        assert_eq!(
1548            config.python_maximum_signature_line_length,
1549            Some(0),
1550            "an explicit 0 is NOT the same as unset — max_len()'s truthiness \
1551             fall-through depends on carrying it through unchanged"
1552        );
1553        assert!(!config.python_trailing_comma_in_multi_line_signatures);
1554        assert!(config.python_display_short_literal_types);
1555        assert!(config.python_use_unqualified_type_names);
1556        assert!(!config.toc_object_entries);
1557        assert_eq!(config.toc_object_entries_show_parents, "all");
1558        assert!(!config.add_function_parentheses);
1559        assert!(!config.add_module_names);
1560        assert!(config.strip_signature_backslash);
1561        assert_eq!(
1562            config.modindex_common_prefix,
1563            vec!["mypkg.".to_string(), "other.".to_string()]
1564        );
1565
1566        // A conf.py that mentions none of them keeps sphinx's defaults.
1567        let untouched = parse("project = 'x'\n")
1568            .extract_configuration()
1569            .unwrap()
1570            .to_build_config()
1571            .unwrap();
1572        assert_eq!(untouched.maximum_signature_line_length, None);
1573        assert!(untouched.add_function_parentheses);
1574        assert!(untouched.toc_object_entries);
1575        assert_eq!(untouched.toc_object_entries_show_parents, "domain");
1576    }
1577
1578    /// A key this crate maps is a *standard* key: it must not also be
1579    /// dumped into `custom_configs`, which is the catch-all for settings
1580    /// only an extension understands.
1581    #[test]
1582    fn the_object_signature_family_is_not_treated_as_custom_config() {
1583        let p = parse(
1584            "maximum_signature_line_length = 88\n\
1585             python_maximum_signature_line_length = 40\n\
1586             python_trailing_comma_in_multi_line_signatures = False\n\
1587             python_display_short_literal_types = True\n\
1588             python_use_unqualified_type_names = True\n\
1589             toc_object_entries = False\n\
1590             toc_object_entries_show_parents = 'all'\n\
1591             add_function_parentheses = False\n\
1592             add_module_names = False\n\
1593             strip_signature_backslash = True\n\
1594             modindex_common_prefix = ['mypkg.']\n\
1595             nitpick_ignore = [('py:func', 'nope')]\n\
1596             nitpick_ignore_regex = [('py:.*', 'nope.*')]\n\
1597             my_extension_knob = 3\n",
1598        );
1599        let config = p.extract_configuration().unwrap();
1600
1601        assert_eq!(
1602            config.custom_configs.keys().collect::<Vec<_>>(),
1603            vec!["my_extension_knob"],
1604            "only the genuinely unknown key is custom: {:?}",
1605            config.custom_configs
1606        );
1607    }
1608
1609    #[test]
1610    fn raw_and_prefixed_string_literals_parse() {
1611        // A raw Rust literal: what follows is byte-for-byte what conf.py holds.
1612        let p = parse(
1613            r"a = r'back\slash'
1614b = R'\d+'
1615c = u'plain'
1616d = rb'bytes'
1617e = 'esc\n'
1618",
1619        );
1620        let ns = |key: &str| p.conf_namespace[key].as_str().unwrap().to_string();
1621        assert_eq!(
1622            ns("a"),
1623            r"back\slash",
1624            "a raw literal keeps its backslashes"
1625        );
1626        assert_eq!(ns("b"), r"\d+");
1627        assert_eq!(ns("c"), "plain");
1628        assert_eq!(ns("d"), "bytes");
1629        assert_eq!(ns("e"), "esc\n", "a plain literal still translates escapes");
1630    }
1631
1632    #[test]
1633    fn the_intersphinx_family_reaches_the_build_config() {
1634        let p = parse(
1635            "intersphinx_mapping = {\n\
1636             'python': ('https://docs.python.org/3', None),\n\
1637             'other': ('https://example.org/', 'local.inv'),\n\
1638             }\n\
1639             intersphinx_disabled_reftypes = ['std:doc', 'std:label']\n\
1640             intersphinx_resolve_self = 'mine'\n\
1641             intersphinx_cache_limit = 0\n\
1642             intersphinx_timeout = 2.5\n\
1643             tls_verify = False\n\
1644             tls_cacerts = '/etc/ca.pem'\n\
1645             user_agent = 'mine/1'\n",
1646        );
1647        let config = p
1648            .extract_configuration()
1649            .unwrap()
1650            .to_build_config()
1651            .expect("a valid mapping must not fail configuration");
1652
1653        assert_eq!(
1654            config.intersphinx_mapping["python"],
1655            ("https://docs.python.org/3".to_string(), vec![None])
1656        );
1657        assert_eq!(
1658            config.intersphinx_mapping["other"],
1659            (
1660                "https://example.org/".to_string(),
1661                vec![Some("local.inv".to_string())]
1662            )
1663        );
1664        assert_eq!(
1665            config.intersphinx_disabled_reftypes,
1666            vec!["std:doc".to_string(), "std:label".to_string()]
1667        );
1668        assert_eq!(config.intersphinx_resolve_self, "mine");
1669        assert_eq!(config.intersphinx_cache_limit, 0);
1670        assert_eq!(config.intersphinx_timeout, Some(2.5));
1671        assert!(!config.tls_verify);
1672        assert_eq!(
1673            config.tls_cacerts,
1674            Some(crate::intersphinx::TlsCacerts::Bundle(
1675                "/etc/ca.pem".to_string()
1676            ))
1677        );
1678        assert_eq!(config.user_agent, Some("mine/1".to_string()));
1679    }
1680
1681    #[test]
1682    fn tls_cacerts_accepts_the_per_host_mapping_form() {
1683        let p = parse("tls_cacerts = {'docs.example.org': '/etc/example.pem'}\n");
1684        let config = p
1685            .extract_configuration()
1686            .unwrap()
1687            .to_build_config()
1688            .unwrap();
1689        assert_eq!(
1690            config.tls_cacerts,
1691            Some(crate::intersphinx::TlsCacerts::PerHost(
1692                std::collections::BTreeMap::from([(
1693                    "docs.example.org".to_string(),
1694                    "/etc/example.pem".to_string()
1695                )])
1696            ))
1697        );
1698    }
1699
1700    #[test]
1701    fn an_invalid_intersphinx_mapping_stops_the_configuration() {
1702        let p = parse("intersphinx_mapping = {'p': 'https://x/'}\n");
1703        let err = p
1704            .extract_configuration()
1705            .unwrap()
1706            .to_build_config()
1707            .expect_err("a malformed entry must abort");
1708        assert_eq!(
1709            err.to_string(),
1710            "Invalid `intersphinx_mapping` configuration (1 error)."
1711        );
1712    }
1713
1714    #[test]
1715    fn a_conf_py_without_numfig_keeps_the_defaults() {
1716        let p = parse("project = 'Docs'\n");
1717        let config = p
1718            .extract_configuration()
1719            .unwrap()
1720            .to_build_config()
1721            .unwrap();
1722
1723        assert!(!config.numfig);
1724        assert_eq!(config.numfig_secnum_depth, 1);
1725        assert_eq!(config.numfig_format["figure"], "Fig. %s");
1726    }
1727
1728    /// conf.py side of `check_confval_types` for the two `int | None` keys
1729    /// (panel fix round B, [18]): an int or `None` is taken as is; any
1730    /// other literal is recorded with its python type name for
1731    /// `BuildConfig::validate` to report, and the key stays unset.
1732    #[test]
1733    fn a_mistyped_none_default_int_key_in_conf_py_is_recorded_not_coerced() {
1734        let p = parse(
1735            "maximum_signature_line_length = '88'\n\
1736             python_maximum_signature_line_length = 42\n",
1737        );
1738        let config = p.extract_configuration().unwrap();
1739        assert_eq!(config.maximum_signature_line_length, None);
1740        assert_eq!(config.python_maximum_signature_line_length, Some(42));
1741        assert_eq!(
1742            config.confval_type_mismatches,
1743            vec![(
1744                "maximum_signature_line_length".to_string(),
1745                "str".to_string()
1746            )]
1747        );
1748        let build = config.to_build_config().unwrap();
1749        assert_eq!(build.maximum_signature_line_length, None);
1750        assert_eq!(
1751            build.validate(),
1752            vec![
1753                "The config value `maximum_signature_line_length' has type `str'; expected \
1754                 `NoneType' or `int'."
1755                    .to_string()
1756            ]
1757        );
1758
1759        for (literal, type_name) in [
1760            ("88.0", "float"),
1761            ("True", "bool"),
1762            ("[88]", "list"),
1763            ("{'a': 1}", "dict"),
1764        ] {
1765            let p = parse(&format!(
1766                "python_maximum_signature_line_length = {literal}\n"
1767            ));
1768            let config = p.extract_configuration().unwrap();
1769            assert_eq!(
1770                config.python_maximum_signature_line_length, None,
1771                "{literal}"
1772            );
1773            assert_eq!(
1774                config.confval_type_mismatches,
1775                vec![(
1776                    "python_maximum_signature_line_length".to_string(),
1777                    type_name.to_string()
1778                )],
1779                "{literal}"
1780            );
1781        }
1782
1783        // `None` and an absent key are the same thing, and neither is a
1784        // mismatch.
1785        let p = parse("maximum_signature_line_length = None\n");
1786        let config = p.extract_configuration().unwrap();
1787        assert_eq!(config.maximum_signature_line_length, None);
1788        assert!(config.confval_type_mismatches.is_empty());
1789    }
1790
1791    /// `source_encoding` is a standard key: read from conf.py, handed to
1792    /// the build configuration, and never dropped into `custom_configs`.
1793    #[test]
1794    fn source_encoding_is_read_from_conf_py() {
1795        let p = parse("source_encoding = 'latin-1'\n");
1796        let config = p.extract_configuration().unwrap();
1797        assert_eq!(config.source_encoding.as_deref(), Some("latin-1"));
1798        assert!(!config.custom_configs.contains_key("source_encoding"));
1799        assert_eq!(config.to_build_config().unwrap().source_encoding, "latin-1");
1800
1801        let p = parse("project = 'x'\n");
1802        let config = p.extract_configuration().unwrap();
1803        assert_eq!(config.source_encoding, None);
1804        assert_eq!(
1805            config.to_build_config().unwrap().source_encoding,
1806            "utf-8-sig"
1807        );
1808    }
1809}