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<String>,
142    pub user_agent: Option<String>,
143
144    // Internationalization
145    pub gettext_uuid: Option<bool>,
146    pub gettext_location: Option<bool>,
147    pub gettext_auto_build: Option<bool>,
148    pub gettext_additional_targets: Vec<String>,
149
150    // Custom configurations (catch-all for extension-specific or custom settings)
151    pub custom_configs: HashMap<String, serde_json::Value>,
152}
153
154impl PythonConfigParser {
155    /// Create a new Python configuration parser
156    pub fn new() -> Result<Self> {
157        Ok(Self {
158            conf_namespace: HashMap::new(),
159            warnings: Vec::new(),
160        })
161    }
162
163    /// Constructs dropped during the last parse (never silently discarded).
164    pub fn warnings(&self) -> &[ConfigWarning] {
165        &self.warnings
166    }
167
168    /// Parse a conf.py file and extract configuration
169    pub fn parse_conf_py<P: AsRef<Path>>(&mut self, conf_py_path: P) -> Result<ConfPyConfig> {
170        let conf_py_path = conf_py_path.as_ref();
171        let _conf_dir = conf_py_path
172            .parent()
173            .ok_or_else(|| anyhow!("Invalid conf.py path"))?;
174
175        // Read the conf.py file
176        let conf_py_content = std::fs::read_to_string(conf_py_path)?;
177
178        self.parse_statements(&conf_py_content)?;
179
180        // Extract configuration values
181        self.extract_configuration()
182    }
183
184    /// Parse the declarative subset of a conf.py: literal assignments, with a
185    /// warning recorded for every construct that had to be dropped.
186    fn parse_statements(&mut self, content: &str) -> Result<()> {
187        for (line, stmt) in logical_statements(content) {
188            let stmt = stmt.trim();
189            if stmt.is_empty() {
190                continue;
191            }
192
193            // Imports set no configuration values; ignoring them loses nothing.
194            if stmt.starts_with("import ") || stmt.starts_with("from ") {
195                continue;
196            }
197
198            match split_assignment(stmt) {
199                Some((name, value_src)) => match parse_python_literal(value_src) {
200                    Ok(value) => {
201                        self.conf_namespace.insert(name.to_string(), value);
202                    }
203                    Err(reason) => self.warnings.push(ConfigWarning {
204                        line,
205                        message: format!(
206                            "unsupported value for '{}' dropped ({}): {}",
207                            name,
208                            reason,
209                            snippet(value_src)
210                        ),
211                    }),
212                },
213                None => self.warnings.push(ConfigWarning {
214                    line,
215                    message: format!("unsupported statement dropped: {}", snippet(stmt)),
216                }),
217            }
218        }
219
220        Ok(())
221    }
222
223    /// Extract configuration values from the parsed Python namespace
224    fn extract_configuration(&self) -> Result<ConfPyConfig> {
225        let mut config = ConfPyConfig::default();
226
227        // Helper function to extract optional string values
228        let extract_string = |key: &str| -> Option<String> {
229            self.conf_namespace
230                .get(key)
231                .and_then(|val| val.as_str().map(|s| s.to_string()))
232        };
233
234        // Helper function to extract optional bool values
235        let extract_bool = |key: &str| -> Option<bool> {
236            self.conf_namespace.get(key).and_then(|val| val.as_bool())
237        };
238
239        // Helper function to extract optional int values
240        let extract_int = |key: &str| -> Option<i32> {
241            self.conf_namespace
242                .get(key)
243                .and_then(|val| val.as_i64().map(|i| i as i32))
244        };
245
246        // Helper function to extract list of strings
247        let extract_string_list = |key: &str| -> Vec<String> {
248            self.conf_namespace
249                .get(key)
250                .and_then(|val| val.as_array())
251                .map(|arr| {
252                    arr.iter()
253                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
254                        .collect()
255                })
256                .unwrap_or_default()
257        };
258
259        // Helper function to extract dictionary
260        let extract_dict = |key: &str| -> HashMap<String, serde_json::Value> {
261            self.conf_namespace
262                .get(key)
263                .and_then(|val| val.as_object())
264                .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
265                .unwrap_or_default()
266        };
267
268        // Extract project information
269        config.project = extract_string("project");
270        config.version = extract_string("version");
271        config.release = extract_string("release");
272        config.copyright = extract_string("copyright");
273        config.author = extract_string("author");
274
275        // Extract general configuration
276        config.extensions = extract_string_list("extensions");
277        config.templates_path = extract_string_list("templates_path");
278        config.exclude_patterns = extract_string_list("exclude_patterns");
279        config.include_patterns = extract_string_list("include_patterns");
280        config.root_doc = extract_string("root_doc").or_else(|| extract_string("master_doc"));
281        config.language = extract_string("language");
282        config.locale_dirs = extract_string_list("locale_dirs");
283        config.gettext_compact = extract_bool("gettext_compact");
284
285        // Extract HTML output options
286        config.html_theme = extract_string("html_theme");
287        config.html_theme_options = extract_dict("html_theme_options");
288        config.html_title = extract_string("html_title");
289        config.html_short_title = extract_string("html_short_title");
290        config.html_logo = extract_string("html_logo");
291        config.html_favicon = extract_string("html_favicon");
292        config.html_css_files = extract_string_list("html_css_files");
293        config.html_js_files = extract_string_list("html_js_files");
294        config.html_static_path = extract_string_list("html_static_path");
295        config.html_extra_path = extract_string_list("html_extra_path");
296        config.html_use_index = extract_bool("html_use_index");
297        config.html_split_index = extract_bool("html_split_index");
298        config.html_copy_source = extract_bool("html_copy_source");
299        config.html_show_sourcelink = extract_bool("html_show_sourcelink");
300        config.html_sourcelink_suffix = extract_string("html_sourcelink_suffix");
301        config.html_use_opensearch = extract_string("html_use_opensearch");
302        config.html_file_suffix = extract_string("html_file_suffix");
303        config.html_link_suffix = extract_string("html_link_suffix");
304        config.html_show_copyright = extract_bool("html_show_copyright");
305        config.html_show_sphinx = extract_bool("html_show_sphinx");
306        config.html_context = extract_dict("html_context");
307        config.html_output_encoding = extract_string("html_output_encoding");
308        config.html_compact_lists = extract_bool("html_compact_lists");
309        config.html_secnumber_suffix = extract_string("html_secnumber_suffix");
310        config.html_search_language = extract_string("html_search_language");
311        config.html_search_options = extract_dict("html_search_options");
312        config.html_search_scorer = extract_string("html_search_scorer");
313        config.html_scaled_image_link = extract_bool("html_scaled_image_link");
314        config.html_baseurl = extract_string("html_baseurl");
315        config.html_codeblock_linenos_style = extract_string("html_codeblock_linenos_style");
316        config.html_math_renderer = extract_string("html_math_renderer");
317        config.html_math_renderer_options = extract_dict("html_math_renderer_options");
318
319        // Extract build options
320        config.needs_sphinx = extract_string("needs_sphinx");
321        config.nitpicky = extract_bool("nitpicky");
322        config.numfig = extract_bool("numfig");
323        config.numfig_secnum_depth = extract_int("numfig_secnum_depth");
324        config.math_number_all = extract_bool("math_number_all");
325        config.math_eqref_format = extract_string("math_eqref_format");
326        config.math_numfig = extract_bool("math_numfig");
327        config.tls_verify = extract_bool("tls_verify");
328        config.tls_cacerts = extract_string("tls_cacerts");
329        config.user_agent = extract_string("user_agent");
330
331        // Extract internationalization
332        config.gettext_uuid = extract_bool("gettext_uuid");
333        config.gettext_location = extract_bool("gettext_location");
334        config.gettext_auto_build = extract_bool("gettext_auto_build");
335        config.gettext_additional_targets = extract_string_list("gettext_additional_targets");
336
337        // Extract custom configurations
338        for (key, value) in &self.conf_namespace {
339            if !Self::is_standard_config_key(key) {
340                config.custom_configs.insert(key.clone(), value.clone());
341            }
342        }
343
344        Ok(config)
345    }
346
347    /// Check if a configuration key is a standard Sphinx configuration
348    fn is_standard_config_key(key: &str) -> bool {
349        matches!(
350            key,
351            "project"
352                | "version"
353                | "release"
354                | "copyright"
355                | "author"
356                | "extensions"
357                | "templates_path"
358                | "exclude_patterns"
359                | "include_patterns"
360                | "source_suffix"
361                | "root_doc"
362                | "master_doc"
363                | "language"
364                | "locale_dirs"
365                | "gettext_compact"
366                | "html_theme"
367                | "html_theme_options"
368                | "html_title"
369                | "html_short_title"
370                | "html_logo"
371                | "html_favicon"
372                | "html_css_files"
373                | "html_js_files"
374                | "html_static_path"
375                | "html_extra_path"
376                | "html_use_index"
377                | "html_split_index"
378                | "html_copy_source"
379                | "html_show_sourcelink"
380                | "html_sourcelink_suffix"
381                | "html_use_opensearch"
382                | "html_file_suffix"
383                | "html_link_suffix"
384                | "html_show_copyright"
385                | "html_show_sphinx"
386                | "html_context"
387                | "html_output_encoding"
388                | "html_compact_lists"
389                | "html_secnumber_suffix"
390                | "html_search_language"
391                | "html_search_options"
392                | "html_search_scorer"
393                | "html_scaled_image_link"
394                | "html_baseurl"
395                | "html_codeblock_linenos_style"
396                | "html_math_renderer"
397                | "html_math_renderer_options"
398                | "needs_sphinx"
399                | "nitpicky"
400                | "numfig"
401                | "numfig_secnum_depth"
402                | "math_number_all"
403                | "math_eqref_format"
404                | "math_numfig"
405                | "tls_verify"
406                | "tls_cacerts"
407                | "user_agent"
408                | "gettext_uuid"
409                | "gettext_location"
410                | "gettext_auto_build"
411                | "gettext_additional_targets"
412        )
413    }
414}
415
416/// First ~60 chars of a construct, for warning messages.
417fn snippet(s: &str) -> String {
418    let s = s.trim();
419    match s.char_indices().nth(60) {
420        Some((idx, _)) => format!("{}…", &s[..idx]),
421        None => s.to_string(),
422    }
423}
424
425/// Split Python source into logical statements: physical lines joined while
426/// brackets are open, a string (incl. triple-quoted) is unterminated, or a
427/// trailing backslash continues the line. Comments outside strings are
428/// stripped. Yields `(1-based start line, statement text)`.
429fn logical_statements(content: &str) -> Vec<(usize, String)> {
430    let chars: Vec<char> = content.chars().collect();
431    let mut statements = Vec::new();
432
433    let mut buf = String::new();
434    let mut start_line = 1usize;
435    let mut line = 1usize;
436    let mut depth = 0i32;
437    // (quote char, is_triple)
438    let mut string_state: Option<(char, bool)> = None;
439    let mut escaped = false;
440
441    let mut i = 0;
442    while i < chars.len() {
443        let c = chars[i];
444
445        if let Some((quote, triple)) = string_state {
446            buf.push(c);
447            if c == '\n' {
448                line += 1;
449            }
450            if escaped {
451                escaped = false;
452            } else if c == '\\' {
453                escaped = true;
454            } else if c == quote {
455                if triple {
456                    if i + 2 < chars.len() && chars[i + 1] == quote && chars[i + 2] == quote {
457                        buf.push(quote);
458                        buf.push(quote);
459                        i += 2;
460                        string_state = None;
461                    }
462                } else {
463                    string_state = None;
464                }
465            }
466            i += 1;
467            continue;
468        }
469
470        match c {
471            '\'' | '"' => {
472                let triple = i + 2 < chars.len() && chars[i + 1] == c && chars[i + 2] == c;
473                buf.push(c);
474                if triple {
475                    buf.push(c);
476                    buf.push(c);
477                    i += 2;
478                }
479                string_state = Some((c, triple));
480            }
481            '#' => {
482                // Comment: skip to (but not past) end of line.
483                while i + 1 < chars.len() && chars[i + 1] != '\n' {
484                    i += 1;
485                }
486            }
487            '(' | '[' | '{' => {
488                depth += 1;
489                buf.push(c);
490            }
491            ')' | ']' | '}' => {
492                depth -= 1;
493                buf.push(c);
494            }
495            '\\' if i + 1 < chars.len() && chars[i + 1] == '\n' => {
496                // Explicit line continuation: join without the backslash.
497                buf.push(' ');
498                line += 1;
499                i += 1;
500            }
501            '\n' => {
502                line += 1;
503                if depth > 0 {
504                    buf.push('\n');
505                } else {
506                    if !buf.trim().is_empty() {
507                        statements.push((start_line, std::mem::take(&mut buf)));
508                    } else {
509                        buf.clear();
510                    }
511                    start_line = line;
512                }
513            }
514            _ => {
515                if buf.trim().is_empty() && !c.is_whitespace() && buf.is_empty() {
516                    start_line = line;
517                }
518                buf.push(c);
519            }
520        }
521        i += 1;
522    }
523
524    if !buf.trim().is_empty() {
525        statements.push((start_line, buf));
526    }
527
528    statements
529}
530
531/// Split `identifier = <value>` at the first top-level `=` that is a plain
532/// assignment (not `==`, `!=`, `<=`, `>=`, or an augmented assignment).
533/// Returns `None` for anything that is not a simple assignment to a bare name.
534fn split_assignment(stmt: &str) -> Option<(&str, &str)> {
535    let bytes = stmt.as_bytes();
536    let mut depth = 0i32;
537    let mut string_quote: Option<u8> = None;
538
539    for i in 0..bytes.len() {
540        let b = bytes[i];
541        if let Some(q) = string_quote {
542            if b == q && (i == 0 || bytes[i - 1] != b'\\') {
543                string_quote = None;
544            }
545            continue;
546        }
547        match b {
548            b'\'' | b'"' => string_quote = Some(b),
549            b'(' | b'[' | b'{' => depth += 1,
550            b')' | b']' | b'}' => depth -= 1,
551            b'=' if depth == 0 => {
552                let next_eq = bytes.get(i + 1) == Some(&b'=');
553                let prev = if i > 0 { bytes[i - 1] } else { 0 };
554                if next_eq || matches!(prev, b'=' | b'!' | b'<' | b'>') {
555                    return None; // comparison
556                }
557                if matches!(
558                    prev,
559                    b'+' | b'-' | b'*' | b'/' | b'%' | b'&' | b'|' | b'^' | b'@'
560                ) {
561                    return None; // augmented assignment
562                }
563                let name = stmt[..i].trim();
564                let is_identifier = !name.is_empty()
565                    && name
566                        .chars()
567                        .next()
568                        .map(|c| c.is_ascii_alphabetic() || c == '_')
569                        .unwrap_or(false)
570                    && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
571                if !is_identifier {
572                    return None;
573                }
574                return Some((name, stmt[i + 1..].trim()));
575            }
576            _ => {}
577        }
578    }
579    None
580}
581
582/// Recursive-descent parser for Python literals → JSON values.
583/// Supports: strings (escapes, implicit adjacent concatenation, triple
584/// quotes), ints/floats, True/False/None, lists, tuples (as arrays), dicts
585/// with string keys, arbitrary nesting, trailing commas.
586fn parse_python_literal(src: &str) -> std::result::Result<serde_json::Value, String> {
587    let chars: Vec<char> = src.chars().collect();
588    let mut p = PyLiteralParser {
589        chars,
590        pos: 0,
591        saw_comma: false,
592    };
593    let value = p.parse_value()?;
594    p.skip_ws();
595    if p.pos < p.chars.len() {
596        return Err("trailing expression".to_string());
597    }
598    Ok(value)
599}
600
601struct PyLiteralParser {
602    chars: Vec<char>,
603    pos: usize,
604    /// Whether the most recently closed sequence contained a comma — used to
605    /// tell a parenthesized grouping `(x)` from a one-element tuple `(x,)`.
606    saw_comma: bool,
607}
608
609impl PyLiteralParser {
610    fn peek(&self) -> Option<char> {
611        self.chars.get(self.pos).copied()
612    }
613
614    fn skip_ws(&mut self) {
615        while matches!(self.peek(), Some(c) if c.is_whitespace()) {
616            self.pos += 1;
617        }
618    }
619
620    fn parse_value(&mut self) -> std::result::Result<serde_json::Value, String> {
621        self.skip_ws();
622        match self.peek() {
623            Some('\'') | Some('"') => {
624                let mut s = self.parse_string()?;
625                // Implicit adjacent string concatenation: 'a' 'b' == 'ab'
626                loop {
627                    self.skip_ws();
628                    match self.peek() {
629                        Some('\'') | Some('"') => s.push_str(&self.parse_string()?),
630                        _ => break,
631                    }
632                }
633                Ok(serde_json::Value::String(s))
634            }
635            Some('[') => self.parse_sequence('[', ']'),
636            Some('(') => {
637                // Python: `(x)` is grouping, `(x,)` / `(x, y)` is a tuple.
638                // Either way an array (or the inner value) serves config needs.
639                let value = self.parse_sequence('(', ')')?;
640                match value {
641                    serde_json::Value::Array(items) if items.len() == 1 && !self.saw_comma => {
642                        Ok(items.into_iter().next().unwrap())
643                    }
644                    other => Ok(other),
645                }
646            }
647            Some('{') => self.parse_dict(),
648            Some(c) if c.is_ascii_digit() || c == '-' || c == '+' || c == '.' => {
649                self.parse_number()
650            }
651            Some(_) => {
652                if self.eat_keyword("True") {
653                    Ok(serde_json::Value::Bool(true))
654                } else if self.eat_keyword("False") {
655                    Ok(serde_json::Value::Bool(false))
656                } else if self.eat_keyword("None") {
657                    Ok(serde_json::Value::Null)
658                } else {
659                    Err("unsupported expression".to_string())
660                }
661            }
662            None => Err("empty value".to_string()),
663        }
664    }
665
666    fn eat_keyword(&mut self, kw: &str) -> bool {
667        let end = self.pos + kw.len();
668        if end <= self.chars.len() && self.chars[self.pos..end].iter().collect::<String>() == kw {
669            let boundary = self
670                .chars
671                .get(end)
672                .map(|c| !c.is_ascii_alphanumeric() && *c != '_')
673                .unwrap_or(true);
674            if boundary {
675                self.pos = end;
676                return true;
677            }
678        }
679        false
680    }
681
682    fn parse_string(&mut self) -> std::result::Result<String, String> {
683        let quote = self.peek().ok_or("expected string")?;
684        self.pos += 1;
685        let triple = self.chars.get(self.pos) == Some(&quote)
686            && self.chars.get(self.pos + 1) == Some(&quote);
687        if triple {
688            self.pos += 2;
689        }
690
691        let mut out = String::new();
692        loop {
693            let c = *self
694                .chars
695                .get(self.pos)
696                .ok_or("unterminated string literal")?;
697            if c == '\\' {
698                let next = *self
699                    .chars
700                    .get(self.pos + 1)
701                    .ok_or("unterminated escape sequence")?;
702                let translated = match next {
703                    'n' => '\n',
704                    't' => '\t',
705                    'r' => '\r',
706                    '\\' => '\\',
707                    '\'' => '\'',
708                    '"' => '"',
709                    other => {
710                        // Unknown escape: Python keeps the backslash.
711                        out.push('\\');
712                        other
713                    }
714                };
715                out.push(translated);
716                self.pos += 2;
717                continue;
718            }
719            if c == quote {
720                if triple {
721                    if self.chars.get(self.pos + 1) == Some(&quote)
722                        && self.chars.get(self.pos + 2) == Some(&quote)
723                    {
724                        self.pos += 3;
725                        return Ok(out);
726                    }
727                } else {
728                    self.pos += 1;
729                    return Ok(out);
730                }
731            }
732            out.push(c);
733            self.pos += 1;
734        }
735    }
736
737    fn parse_number(&mut self) -> std::result::Result<serde_json::Value, String> {
738        let start = self.pos;
739        if matches!(self.peek(), Some('-') | Some('+')) {
740            self.pos += 1;
741        }
742        while matches!(self.peek(), Some(c) if c.is_ascii_digit() || c == '.' || c == '_' || c == 'e' || c == 'E')
743        {
744            self.pos += 1;
745        }
746        let text: String = self.chars[start..self.pos]
747            .iter()
748            .filter(|c| **c != '_')
749            .collect();
750        if let Ok(i) = text.parse::<i64>() {
751            return Ok(serde_json::Value::Number(i.into()));
752        }
753        if let Ok(f) = text.parse::<f64>() {
754            if let Some(n) = serde_json::Number::from_f64(f) {
755                return Ok(serde_json::Value::Number(n));
756            }
757        }
758        Err(format!("invalid number '{text}'"))
759    }
760
761    fn parse_sequence(
762        &mut self,
763        open: char,
764        close: char,
765    ) -> std::result::Result<serde_json::Value, String> {
766        debug_assert_eq!(self.peek(), Some(open));
767        self.pos += 1;
768        self.saw_comma = false;
769        let mut items = Vec::new();
770        let mut saw_comma = false;
771        loop {
772            self.skip_ws();
773            if self.peek() == Some(close) {
774                self.pos += 1;
775                self.saw_comma = saw_comma;
776                return Ok(serde_json::Value::Array(items));
777            }
778            items.push(self.parse_value()?);
779            self.skip_ws();
780            match self.peek() {
781                Some(',') => {
782                    saw_comma = true;
783                    self.pos += 1;
784                }
785                Some(c) if c == close => {}
786                _ => return Err(format!("expected ',' or '{close}'")),
787            }
788        }
789    }
790
791    fn parse_dict(&mut self) -> std::result::Result<serde_json::Value, String> {
792        debug_assert_eq!(self.peek(), Some('{'));
793        self.pos += 1;
794        let mut map = serde_json::Map::new();
795        loop {
796            self.skip_ws();
797            if self.peek() == Some('}') {
798                self.pos += 1;
799                return Ok(serde_json::Value::Object(map));
800            }
801            let key = match self.parse_value()? {
802                serde_json::Value::String(s) => s,
803                other => return Err(format!("non-string dict key {other}")),
804            };
805            self.skip_ws();
806            if self.peek() != Some(':') {
807                return Err("expected ':' in dict".to_string());
808            }
809            self.pos += 1;
810            let value = self.parse_value()?;
811            map.insert(key, value);
812            self.skip_ws();
813            match self.peek() {
814                Some(',') => {
815                    self.pos += 1;
816                }
817                Some('}') => {}
818                _ => return Err("expected ',' or '}'".to_string()),
819            }
820        }
821    }
822}
823
824impl Default for ConfPyConfig {
825    fn default() -> Self {
826        Self {
827            project: None,
828            version: None,
829            release: None,
830            copyright: None,
831            author: None,
832            extensions: Vec::new(),
833            templates_path: vec!["_templates".to_string()],
834            exclude_patterns: Vec::new(),
835            include_patterns: vec!["**".to_string()], // Sphinx default
836            source_suffix: HashMap::new(),
837            root_doc: Some("index".to_string()),
838            language: None,
839            locale_dirs: vec!["locales".to_string()],
840            gettext_compact: Some(true),
841            html_theme: Some("alabaster".to_string()),
842            html_theme_options: HashMap::new(),
843            html_title: None,
844            html_short_title: None,
845            html_logo: None,
846            html_favicon: None,
847            html_css_files: Vec::new(),
848            html_js_files: Vec::new(),
849            html_static_path: vec!["_static".to_string()],
850            html_extra_path: Vec::new(),
851            html_use_index: Some(true),
852            html_split_index: Some(false),
853            html_copy_source: Some(true),
854            html_show_sourcelink: Some(true),
855            html_sourcelink_suffix: Some(".txt".to_string()),
856            html_use_opensearch: None,
857            html_file_suffix: Some(".html".to_string()),
858            html_link_suffix: Some(".html".to_string()),
859            html_show_copyright: Some(true),
860            html_show_sphinx: Some(true),
861            html_context: HashMap::new(),
862            html_output_encoding: Some("utf-8".to_string()),
863            html_compact_lists: Some(true),
864            html_secnumber_suffix: Some(". ".to_string()),
865            html_search_language: None,
866            html_search_options: HashMap::new(),
867            html_search_scorer: None,
868            html_scaled_image_link: Some(true),
869            html_baseurl: None,
870            html_codeblock_linenos_style: Some("table".to_string()),
871            html_math_renderer: Some("mathjax".to_string()),
872            html_math_renderer_options: HashMap::new(),
873            latex_engine: Some("pdflatex".to_string()),
874            latex_documents: Vec::new(),
875            latex_logo: None,
876            latex_appendices: Vec::new(),
877            latex_domain_indices: Some(true),
878            latex_show_pagerefs: Some(false),
879            latex_show_urls: Some("no".to_string()),
880            latex_use_latex_multicolumn: Some(false),
881            latex_use_xindy: Some(false),
882            latex_toplevel_sectioning: None,
883            latex_docclass: HashMap::new(),
884            latex_additional_files: Vec::new(),
885            latex_elements: HashMap::new(),
886            epub_title: None,
887            epub_author: None,
888            epub_language: None,
889            epub_publisher: None,
890            epub_copyright: None,
891            epub_identifier: None,
892            epub_scheme: None,
893            epub_uid: None,
894            epub_cover: None,
895            epub_css_files: Vec::new(),
896            epub_pre_files: Vec::new(),
897            epub_post_files: Vec::new(),
898            epub_exclude_files: Vec::new(),
899            epub_tocdepth: Some(3),
900            epub_tocdup: Some(true),
901            epub_tocscope: Some("default".to_string()),
902            epub_fix_images: Some(false),
903            epub_max_image_width: Some(0),
904            epub_show_urls: Some("inline".to_string()),
905            epub_use_index: Some(true),
906            epub_description: None,
907            epub_contributor: None,
908            epub_writing_mode: Some("horizontal".to_string()),
909            extension_configs: HashMap::new(),
910            needs_sphinx: None,
911            needs_extensions: HashMap::new(),
912            manpages_url: None,
913            nitpicky: Some(false),
914            nitpick_ignore: Vec::new(),
915            nitpick_ignore_regex: Vec::new(),
916            numfig: Some(false),
917            numfig_format: HashMap::new(),
918            numfig_secnum_depth: Some(1),
919            math_number_all: Some(false),
920            math_eqref_format: None,
921            math_numfig: Some(true),
922            tls_verify: Some(true),
923            tls_cacerts: None,
924            user_agent: None,
925            gettext_uuid: Some(false),
926            gettext_location: Some(true),
927            gettext_auto_build: Some(true),
928            gettext_additional_targets: Vec::new(),
929            custom_configs: HashMap::new(),
930        }
931    }
932}
933
934impl ConfPyConfig {
935    /// Convert conf.py configuration to BuildConfig
936    pub fn to_build_config(&self) -> BuildConfig {
937        let mut config = BuildConfig::default();
938
939        // Map basic project information
940        if let Some(project) = &self.project {
941            config.project = project.clone();
942        }
943        if let Some(version) = &self.version {
944            config.version = Some(version.clone());
945        }
946        if let Some(release) = &self.release {
947            config.release = Some(release.clone());
948        }
949        if let Some(copyright) = &self.copyright {
950            config.copyright = Some(copyright.clone());
951        }
952        if let Some(language) = &self.language {
953            config.language = Some(language.clone());
954        }
955        if let Some(root_doc) = &self.root_doc {
956            config.root_doc = Some(root_doc.clone());
957        }
958
959        // Map extensions
960        config.extensions = self.extensions.clone();
961
962        // Map template paths
963        config.template_dirs = self.templates_path.iter().map(PathBuf::from).collect();
964
965        // Map static paths
966        config.static_dirs = self.html_static_path.iter().map(PathBuf::from).collect();
967        config.html_static_path = self.html_static_path.iter().map(PathBuf::from).collect();
968
969        // Map HTML configuration
970        if let Some(html_theme) = &self.html_theme {
971            config.output.html_theme = html_theme.clone();
972            config.theme.name = html_theme.clone();
973        }
974        if let Some(html_title) = &self.html_title {
975            config.html_title = Some(html_title.clone());
976        }
977        if let Some(html_short_title) = &self.html_short_title {
978            config.html_short_title = Some(html_short_title.clone());
979        }
980        if let Some(html_logo) = &self.html_logo {
981            config.html_logo = Some(html_logo.clone());
982        }
983        if let Some(html_favicon) = &self.html_favicon {
984            config.html_favicon = Some(html_favicon.clone());
985        }
986        config.html_css_files = self.html_css_files.clone();
987        config.html_js_files = self.html_js_files.clone();
988        if let Some(html_show_copyright) = self.html_show_copyright {
989            config.html_show_copyright = Some(html_show_copyright);
990        }
991        if let Some(html_show_sphinx) = self.html_show_sphinx {
992            config.html_show_sphinx = Some(html_show_sphinx);
993        }
994        if let Some(html_copy_source) = self.html_copy_source {
995            config.html_copy_source = Some(html_copy_source);
996        }
997        if let Some(html_show_sourcelink) = self.html_show_sourcelink {
998            config.html_show_sourcelink = Some(html_show_sourcelink);
999        }
1000        if let Some(html_sourcelink_suffix) = &self.html_sourcelink_suffix {
1001            config.html_sourcelink_suffix = Some(html_sourcelink_suffix.clone());
1002        }
1003        if let Some(html_use_index) = self.html_use_index {
1004            config.html_use_index = Some(html_use_index);
1005        }
1006        if let Some(html_use_opensearch) = &self.html_use_opensearch {
1007            config.html_use_opensearch = Some(!html_use_opensearch.is_empty());
1008        }
1009        if let Some(html_last_updated_fmt) = &self.html_context.get("last_updated") {
1010            if let Some(fmt_str) = html_last_updated_fmt.as_str() {
1011                config.html_last_updated_fmt = Some(fmt_str.to_string());
1012            }
1013        }
1014
1015        // Map templates path
1016        config.templates_path = self.templates_path.iter().map(PathBuf::from).collect();
1017
1018        // Map file patterns (Sphinx compatibility)
1019        config.include_patterns = if self.include_patterns.is_empty() {
1020            vec!["**".to_string()] // Sphinx default
1021        } else {
1022            self.include_patterns.clone()
1023        };
1024        config.exclude_patterns = self.exclude_patterns.clone();
1025
1026        config.nitpicky = self.nitpicky.unwrap_or(false);
1027        config.html_context = self.html_context.clone();
1028
1029        config
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    fn parse(content: &str) -> PythonConfigParser {
1038        let mut parser = PythonConfigParser::new().unwrap();
1039        parser.parse_statements(content).unwrap();
1040        parser
1041    }
1042
1043    #[test]
1044    fn multiline_list_parses() {
1045        let p = parse("extensions = [\n    'sphinx.ext.autodoc',\n    'sphinx.ext.viewcode',\n]\n");
1046        let v = p.conf_namespace.get("extensions").expect("extensions set");
1047        let items: Vec<&str> = v
1048            .as_array()
1049            .unwrap()
1050            .iter()
1051            .map(|i| i.as_str().unwrap())
1052            .collect();
1053        assert_eq!(items, vec!["sphinx.ext.autodoc", "sphinx.ext.viewcode"]);
1054        assert!(p.warnings().is_empty(), "warnings: {:?}", p.warnings());
1055    }
1056
1057    #[test]
1058    fn multiline_dict_parses() {
1059        let p = parse(
1060            "html_theme_options = {\n    'collapse_navigation': False,\n    'navigation_depth': 4,\n}\n",
1061        );
1062        let v = p
1063            .conf_namespace
1064            .get("html_theme_options")
1065            .expect("dict set");
1066        let obj = v.as_object().unwrap();
1067        assert_eq!(
1068            obj.get("collapse_navigation"),
1069            Some(&serde_json::Value::Bool(false))
1070        );
1071        assert_eq!(
1072            obj.get("navigation_depth").and_then(|n| n.as_i64()),
1073            Some(4)
1074        );
1075    }
1076
1077    #[test]
1078    fn adjacent_string_concat_parses() {
1079        let p = parse("copyright = ('2024, ' 'Team')\n");
1080        assert_eq!(
1081            p.conf_namespace.get("copyright").and_then(|v| v.as_str()),
1082            Some("2024, Team")
1083        );
1084    }
1085
1086    #[test]
1087    fn triple_quoted_string_parses() {
1088        let p = parse("project = \"\"\"Multi\nLine\"\"\"\n");
1089        assert_eq!(
1090            p.conf_namespace.get("project").and_then(|v| v.as_str()),
1091            Some("Multi\nLine")
1092        );
1093    }
1094
1095    #[test]
1096    fn trailing_comment_stripped() {
1097        let p = parse("version = '1.0'  # the version\n");
1098        assert_eq!(
1099            p.conf_namespace.get("version").and_then(|v| v.as_str()),
1100            Some("1.0")
1101        );
1102    }
1103
1104    #[test]
1105    fn unsupported_value_warns_and_drops() {
1106        let p = parse("project = os.environ['P']\n");
1107        assert!(!p.conf_namespace.contains_key("project"));
1108        assert_eq!(p.warnings().len(), 1);
1109        assert_eq!(p.warnings()[0].line, 1);
1110        assert!(
1111            p.warnings()[0].message.contains("project"),
1112            "warning names the variable: {}",
1113            p.warnings()[0].message
1114        );
1115    }
1116
1117    #[test]
1118    fn unsupported_statement_warns_but_imports_do_not() {
1119        let p = parse("import os\nfrom pathlib import Path\nsys.path.insert(0, 'x')\n");
1120        assert_eq!(p.warnings().len(), 1, "warnings: {:?}", p.warnings());
1121        assert_eq!(p.warnings()[0].line, 3);
1122    }
1123
1124    #[test]
1125    fn nested_structures_parse() {
1126        let p = parse(
1127            "intersphinx_mapping = {\n    'python': ('https://docs.python.org/3', None),\n}\n",
1128        );
1129        let v = p.conf_namespace.get("intersphinx_mapping").unwrap();
1130        let python = v
1131            .as_object()
1132            .unwrap()
1133            .get("python")
1134            .unwrap()
1135            .as_array()
1136            .unwrap();
1137        assert_eq!(python[0].as_str(), Some("https://docs.python.org/3"));
1138        assert!(python[1].is_null());
1139    }
1140}