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