1use anyhow::{anyhow, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::config::BuildConfig;
7
8pub struct PythonConfigParser {
17 conf_namespace: HashMap<String, serde_json::Value>,
18 warnings: Vec<ConfigWarning>,
19}
20
21#[derive(Debug, Clone)]
23pub struct ConfigWarning {
24 pub line: usize,
26 pub message: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ConfPyConfig {
32 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 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 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 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 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 pub extension_configs: HashMap<String, HashMap<String, serde_json::Value>>,
126
127 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 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 pub custom_configs: HashMap<String, serde_json::Value>,
152}
153
154impl PythonConfigParser {
155 pub fn new() -> Result<Self> {
157 Ok(Self {
158 conf_namespace: HashMap::new(),
159 warnings: Vec::new(),
160 })
161 }
162
163 pub fn warnings(&self) -> &[ConfigWarning] {
165 &self.warnings
166 }
167
168 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 let conf_py_content = std::fs::read_to_string(conf_py_path)?;
177
178 self.parse_statements(&conf_py_content)?;
179
180 self.extract_configuration()
182 }
183
184 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 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 fn extract_configuration(&self) -> Result<ConfPyConfig> {
225 let mut config = ConfPyConfig::default();
226
227 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 let extract_bool = |key: &str| -> Option<bool> {
236 self.conf_namespace.get(key).and_then(|val| val.as_bool())
237 };
238
239 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 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 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 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 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 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 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 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 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 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
416fn 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
425fn 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 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 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 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
531fn 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; }
557 if matches!(
558 prev,
559 b'+' | b'-' | b'*' | b'/' | b'%' | b'&' | b'|' | b'^' | b'@'
560 ) {
561 return None; }
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
582fn 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 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 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 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("e)
686 && self.chars.get(self.pos + 1) == Some("e);
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 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("e)
722 && self.chars.get(self.pos + 2) == Some("e)
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()], 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 pub fn to_build_config(&self) -> BuildConfig {
937 let mut config = BuildConfig::default();
938
939 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 config.extensions = self.extensions.clone();
961
962 config.template_dirs = self.templates_path.iter().map(PathBuf::from).collect();
964
965 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 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 config.templates_path = self.templates_path.iter().map(PathBuf::from).collect();
1017
1018 config.include_patterns = if self.include_patterns.is_empty() {
1020 vec!["**".to_string()] } 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}