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