Skip to main content

sphinx_ultra/
python_config.rs

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