use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config::BuildConfig;
pub struct PythonConfigParser {
conf_namespace: HashMap<String, serde_json::Value>,
warnings: Vec<ConfigWarning>,
}
#[derive(Debug, Clone)]
pub struct ConfigWarning {
pub line: usize,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfPyConfig {
pub project: Option<String>,
pub version: Option<String>,
pub release: Option<String>,
pub copyright: Option<String>,
pub author: Option<String>,
pub extensions: Vec<String>,
pub templates_path: Vec<String>,
pub exclude_patterns: Vec<String>,
pub include_patterns: Vec<String>,
pub source_suffix: HashMap<String, String>,
pub root_doc: Option<String>,
pub language: Option<String>,
pub locale_dirs: Vec<String>,
pub gettext_compact: Option<bool>,
pub html_theme: Option<String>,
pub html_theme_options: HashMap<String, serde_json::Value>,
pub html_title: Option<String>,
pub html_short_title: Option<String>,
pub html_logo: Option<String>,
pub html_favicon: Option<String>,
pub html_css_files: Vec<String>,
pub html_js_files: Vec<String>,
pub html_static_path: Vec<String>,
pub html_extra_path: Vec<String>,
pub html_use_index: Option<bool>,
pub html_split_index: Option<bool>,
pub html_copy_source: Option<bool>,
pub html_show_sourcelink: Option<bool>,
pub html_sourcelink_suffix: Option<String>,
pub html_use_opensearch: Option<String>,
pub html_file_suffix: Option<String>,
pub html_link_suffix: Option<String>,
pub html_show_copyright: Option<bool>,
pub html_show_sphinx: Option<bool>,
pub html_context: HashMap<String, serde_json::Value>,
pub html_output_encoding: Option<String>,
pub html_compact_lists: Option<bool>,
pub html_secnumber_suffix: Option<String>,
pub html_search_language: Option<String>,
pub html_search_options: HashMap<String, serde_json::Value>,
pub html_search_scorer: Option<String>,
pub html_scaled_image_link: Option<bool>,
pub html_baseurl: Option<String>,
pub html_codeblock_linenos_style: Option<String>,
pub html_math_renderer: Option<String>,
pub html_math_renderer_options: HashMap<String, serde_json::Value>,
pub latex_engine: Option<String>,
pub latex_documents: Vec<(String, String, String, String, String)>,
pub latex_logo: Option<String>,
pub latex_appendices: Vec<String>,
pub latex_domain_indices: Option<bool>,
pub latex_show_pagerefs: Option<bool>,
pub latex_show_urls: Option<String>,
pub latex_use_latex_multicolumn: Option<bool>,
pub latex_use_xindy: Option<bool>,
pub latex_toplevel_sectioning: Option<String>,
pub latex_docclass: HashMap<String, String>,
pub latex_additional_files: Vec<String>,
pub latex_elements: HashMap<String, String>,
pub epub_title: Option<String>,
pub epub_author: Option<String>,
pub epub_language: Option<String>,
pub epub_publisher: Option<String>,
pub epub_copyright: Option<String>,
pub epub_identifier: Option<String>,
pub epub_scheme: Option<String>,
pub epub_uid: Option<String>,
pub epub_cover: Option<(String, String)>,
pub epub_css_files: Vec<String>,
pub epub_pre_files: Vec<(String, String)>,
pub epub_post_files: Vec<(String, String)>,
pub epub_exclude_files: Vec<String>,
pub epub_tocdepth: Option<i32>,
pub epub_tocdup: Option<bool>,
pub epub_tocscope: Option<String>,
pub epub_fix_images: Option<bool>,
pub epub_max_image_width: Option<i32>,
pub epub_show_urls: Option<String>,
pub epub_use_index: Option<bool>,
pub epub_description: Option<String>,
pub epub_contributor: Option<String>,
pub epub_writing_mode: Option<String>,
pub extension_configs: HashMap<String, HashMap<String, serde_json::Value>>,
pub needs_sphinx: Option<String>,
pub needs_extensions: HashMap<String, String>,
pub manpages_url: Option<String>,
pub nitpicky: Option<bool>,
pub nitpick_ignore: Vec<(String, String)>,
pub nitpick_ignore_regex: Vec<(String, String)>,
pub numfig: Option<bool>,
pub numfig_format: HashMap<String, String>,
pub numfig_secnum_depth: Option<i32>,
pub math_number_all: Option<bool>,
pub math_eqref_format: Option<String>,
pub math_numfig: Option<bool>,
pub tls_verify: Option<bool>,
pub tls_cacerts: Option<crate::intersphinx::TlsCacerts>,
pub user_agent: Option<String>,
pub maximum_signature_line_length: Option<i64>,
pub python_maximum_signature_line_length: Option<i64>,
pub python_trailing_comma_in_multi_line_signatures: Option<bool>,
pub python_display_short_literal_types: Option<bool>,
pub python_use_unqualified_type_names: Option<bool>,
pub toc_object_entries: Option<bool>,
pub toc_object_entries_show_parents: Option<String>,
pub source_encoding: Option<String>,
pub confval_type_mismatches: Vec<(String, String)>,
pub add_function_parentheses: Option<bool>,
pub add_module_names: Option<bool>,
pub strip_signature_backslash: Option<bool>,
pub modindex_common_prefix: Vec<String>,
pub intersphinx_mapping: serde_json::Value,
pub intersphinx_disabled_reftypes: Option<Vec<String>>,
pub intersphinx_resolve_self: Option<String>,
pub intersphinx_cache_limit: Option<i64>,
pub intersphinx_timeout: Option<f64>,
pub gettext_uuid: Option<bool>,
pub gettext_location: Option<bool>,
pub gettext_auto_build: Option<bool>,
pub gettext_additional_targets: Vec<String>,
pub custom_configs: HashMap<String, serde_json::Value>,
}
impl PythonConfigParser {
pub fn new() -> Result<Self> {
Ok(Self {
conf_namespace: HashMap::new(),
warnings: Vec::new(),
})
}
pub fn warnings(&self) -> &[ConfigWarning] {
&self.warnings
}
pub fn parse_conf_py<P: AsRef<Path>>(&mut self, conf_py_path: P) -> Result<ConfPyConfig> {
let conf_py_path = conf_py_path.as_ref();
let _conf_dir = conf_py_path
.parent()
.ok_or_else(|| anyhow!("Invalid conf.py path"))?;
let conf_py_content = std::fs::read_to_string(conf_py_path)?;
self.parse_statements(&conf_py_content)?;
self.extract_configuration()
}
fn parse_statements(&mut self, content: &str) -> Result<()> {
for (line, stmt) in logical_statements(content) {
let stmt = stmt.trim();
if stmt.is_empty() {
continue;
}
if stmt.starts_with("import ") || stmt.starts_with("from ") {
continue;
}
match split_assignment(stmt) {
Some((name, value_src)) => match parse_python_literal(value_src) {
Ok(value) => {
self.conf_namespace.insert(name.to_string(), value);
}
Err(reason) => self.warnings.push(ConfigWarning {
line,
message: format!(
"unsupported value for '{}' dropped ({}): {}",
name,
reason,
snippet(value_src)
),
}),
},
None => self.warnings.push(ConfigWarning {
line,
message: format!("unsupported statement dropped: {}", snippet(stmt)),
}),
}
}
Ok(())
}
fn extract_configuration(&self) -> Result<ConfPyConfig> {
let mut config = ConfPyConfig::default();
let extract_string = |key: &str| -> Option<String> {
self.conf_namespace
.get(key)
.and_then(|val| val.as_str().map(|s| s.to_string()))
};
let extract_bool = |key: &str| -> Option<bool> {
self.conf_namespace.get(key).and_then(|val| val.as_bool())
};
let extract_int = |key: &str| -> Option<i32> {
self.conf_namespace
.get(key)
.and_then(|val| val.as_i64().map(|i| i as i32))
};
let extract_string_list = |key: &str| -> Vec<String> {
self.conf_namespace
.get(key)
.and_then(|val| val.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
};
let extract_pair_list = |key: &str| -> Vec<(String, String)> {
self.conf_namespace
.get(key)
.and_then(|val| val.as_array())
.map(|arr| {
arr.iter()
.filter_map(|pair| {
let pair = pair.as_array()?;
let first = pair.first()?.as_str()?;
let second = pair.get(1)?.as_str()?;
Some((first.to_string(), second.to_string()))
})
.collect()
})
.unwrap_or_default()
};
let extract_dict = |key: &str| -> HashMap<String, serde_json::Value> {
self.conf_namespace
.get(key)
.and_then(|val| val.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
};
config.project = extract_string("project");
config.version = extract_string("version");
config.release = extract_string("release");
config.copyright = extract_string("copyright");
config.author = extract_string("author");
config.extensions = extract_string_list("extensions");
config.templates_path = extract_string_list("templates_path");
config.exclude_patterns = extract_string_list("exclude_patterns");
config.include_patterns = extract_string_list("include_patterns");
config.root_doc = extract_string("root_doc").or_else(|| extract_string("master_doc"));
config.language = extract_string("language");
config.locale_dirs = extract_string_list("locale_dirs");
config.gettext_compact = extract_bool("gettext_compact");
config.html_theme = extract_string("html_theme");
config.html_theme_options = extract_dict("html_theme_options");
config.html_title = extract_string("html_title");
config.html_short_title = extract_string("html_short_title");
config.html_logo = extract_string("html_logo");
config.html_favicon = extract_string("html_favicon");
config.html_css_files = extract_string_list("html_css_files");
config.html_js_files = extract_string_list("html_js_files");
config.html_static_path = extract_string_list("html_static_path");
config.html_extra_path = extract_string_list("html_extra_path");
config.html_use_index = extract_bool("html_use_index");
config.html_split_index = extract_bool("html_split_index");
config.html_copy_source = extract_bool("html_copy_source");
config.html_show_sourcelink = extract_bool("html_show_sourcelink");
config.html_sourcelink_suffix = extract_string("html_sourcelink_suffix");
config.html_use_opensearch = extract_string("html_use_opensearch");
config.html_file_suffix = extract_string("html_file_suffix");
config.html_link_suffix = extract_string("html_link_suffix");
config.html_show_copyright = extract_bool("html_show_copyright");
config.html_show_sphinx = extract_bool("html_show_sphinx");
config.html_context = extract_dict("html_context");
config.html_output_encoding = extract_string("html_output_encoding");
config.html_compact_lists = extract_bool("html_compact_lists");
config.html_secnumber_suffix = extract_string("html_secnumber_suffix");
config.html_search_language = extract_string("html_search_language");
config.html_search_options = extract_dict("html_search_options");
config.html_search_scorer = extract_string("html_search_scorer");
config.html_scaled_image_link = extract_bool("html_scaled_image_link");
config.html_baseurl = extract_string("html_baseurl");
config.html_codeblock_linenos_style = extract_string("html_codeblock_linenos_style");
config.html_math_renderer = extract_string("html_math_renderer");
config.html_math_renderer_options = extract_dict("html_math_renderer_options");
config.needs_sphinx = extract_string("needs_sphinx");
config.nitpicky = extract_bool("nitpicky");
config.nitpick_ignore = extract_pair_list("nitpick_ignore");
config.nitpick_ignore_regex = extract_pair_list("nitpick_ignore_regex");
config.numfig = extract_bool("numfig");
config.numfig_format = extract_dict("numfig_format")
.into_iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
.collect();
config.numfig_secnum_depth = extract_int("numfig_secnum_depth");
config.math_number_all = extract_bool("math_number_all");
config.math_eqref_format = extract_string("math_eqref_format");
config.math_numfig = extract_bool("math_numfig");
config.tls_verify = extract_bool("tls_verify");
config.tls_cacerts = self
.conf_namespace
.get("tls_cacerts")
.and_then(|value| match value {
serde_json::Value::String(path) => {
Some(crate::intersphinx::TlsCacerts::Bundle(path.clone()))
}
serde_json::Value::Object(map) => Some(crate::intersphinx::TlsCacerts::PerHost(
map.iter()
.filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_string())))
.collect(),
)),
_ => None,
});
config.user_agent = extract_string("user_agent");
let mut mismatches: Vec<(String, String)> = Vec::new();
let mut extract_none_default_int = |key: &str| -> Option<i64> {
match self.conf_namespace.get(key) {
None | Some(serde_json::Value::Null) => None,
Some(value) => match value.as_i64() {
Some(int) => Some(int),
None => {
mismatches.push((key.to_string(), python_type_name(value).to_string()));
None
}
},
}
};
config.maximum_signature_line_length =
extract_none_default_int("maximum_signature_line_length");
config.python_maximum_signature_line_length =
extract_none_default_int("python_maximum_signature_line_length");
config.confval_type_mismatches = mismatches;
config.source_encoding = extract_string("source_encoding");
config.python_trailing_comma_in_multi_line_signatures =
extract_bool("python_trailing_comma_in_multi_line_signatures");
config.python_display_short_literal_types =
extract_bool("python_display_short_literal_types");
config.python_use_unqualified_type_names =
extract_bool("python_use_unqualified_type_names");
config.toc_object_entries = extract_bool("toc_object_entries");
config.toc_object_entries_show_parents = extract_string("toc_object_entries_show_parents");
config.add_function_parentheses = extract_bool("add_function_parentheses");
config.add_module_names = extract_bool("add_module_names");
config.strip_signature_backslash = extract_bool("strip_signature_backslash");
config.modindex_common_prefix = extract_string_list("modindex_common_prefix");
config.intersphinx_mapping = self
.conf_namespace
.get("intersphinx_mapping")
.cloned()
.unwrap_or(serde_json::Value::Null);
config.intersphinx_disabled_reftypes = self
.conf_namespace
.get("intersphinx_disabled_reftypes")
.map(|_| extract_string_list("intersphinx_disabled_reftypes"));
config.intersphinx_resolve_self = extract_string("intersphinx_resolve_self");
config.intersphinx_cache_limit = self
.conf_namespace
.get("intersphinx_cache_limit")
.and_then(serde_json::Value::as_i64);
config.intersphinx_timeout = self
.conf_namespace
.get("intersphinx_timeout")
.and_then(serde_json::Value::as_f64);
config.gettext_uuid = extract_bool("gettext_uuid");
config.gettext_location = extract_bool("gettext_location");
config.gettext_auto_build = extract_bool("gettext_auto_build");
config.gettext_additional_targets = extract_string_list("gettext_additional_targets");
for (key, value) in &self.conf_namespace {
if !Self::is_standard_config_key(key) {
config.custom_configs.insert(key.clone(), value.clone());
}
}
Ok(config)
}
fn is_standard_config_key(key: &str) -> bool {
matches!(
key,
"project"
| "version"
| "release"
| "copyright"
| "author"
| "extensions"
| "templates_path"
| "exclude_patterns"
| "include_patterns"
| "source_suffix"
| "source_encoding"
| "root_doc"
| "master_doc"
| "language"
| "locale_dirs"
| "gettext_compact"
| "html_theme"
| "html_theme_options"
| "html_title"
| "html_short_title"
| "html_logo"
| "html_favicon"
| "html_css_files"
| "html_js_files"
| "html_static_path"
| "html_extra_path"
| "html_use_index"
| "html_split_index"
| "html_copy_source"
| "html_show_sourcelink"
| "html_sourcelink_suffix"
| "html_use_opensearch"
| "html_file_suffix"
| "html_link_suffix"
| "html_show_copyright"
| "html_show_sphinx"
| "html_context"
| "html_output_encoding"
| "html_compact_lists"
| "html_secnumber_suffix"
| "html_search_language"
| "html_search_options"
| "html_search_scorer"
| "html_scaled_image_link"
| "html_baseurl"
| "html_codeblock_linenos_style"
| "html_math_renderer"
| "html_math_renderer_options"
| "needs_sphinx"
| "nitpicky"
| "nitpick_ignore"
| "nitpick_ignore_regex"
| "maximum_signature_line_length"
| "python_maximum_signature_line_length"
| "python_trailing_comma_in_multi_line_signatures"
| "python_display_short_literal_types"
| "python_use_unqualified_type_names"
| "toc_object_entries"
| "toc_object_entries_show_parents"
| "add_function_parentheses"
| "add_module_names"
| "strip_signature_backslash"
| "modindex_common_prefix"
| "numfig"
| "numfig_format"
| "numfig_secnum_depth"
| "math_number_all"
| "math_eqref_format"
| "math_numfig"
| "tls_verify"
| "tls_cacerts"
| "user_agent"
| "intersphinx_mapping"
| "intersphinx_disabled_reftypes"
| "intersphinx_resolve_self"
| "intersphinx_cache_limit"
| "intersphinx_timeout"
| "gettext_uuid"
| "gettext_location"
| "gettext_auto_build"
| "gettext_additional_targets"
)
}
}
fn python_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "NoneType",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "int",
serde_json::Value::Number(_) => "float",
serde_json::Value::String(_) => "str",
serde_json::Value::Array(_) => "list",
serde_json::Value::Object(_) => "dict",
}
}
fn snippet(s: &str) -> String {
let s = s.trim();
match s.char_indices().nth(60) {
Some((idx, _)) => format!("{}…", &s[..idx]),
None => s.to_string(),
}
}
fn logical_statements(content: &str) -> Vec<(usize, String)> {
let chars: Vec<char> = content.chars().collect();
let mut statements = Vec::new();
let mut buf = String::new();
let mut start_line = 1usize;
let mut line = 1usize;
let mut depth = 0i32;
let mut string_state: Option<(char, bool)> = None;
let mut escaped = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if let Some((quote, triple)) = string_state {
buf.push(c);
if c == '\n' {
line += 1;
}
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == quote {
if triple {
if i + 2 < chars.len() && chars[i + 1] == quote && chars[i + 2] == quote {
buf.push(quote);
buf.push(quote);
i += 2;
string_state = None;
}
} else {
string_state = None;
}
}
i += 1;
continue;
}
match c {
'\'' | '"' => {
let triple = i + 2 < chars.len() && chars[i + 1] == c && chars[i + 2] == c;
buf.push(c);
if triple {
buf.push(c);
buf.push(c);
i += 2;
}
string_state = Some((c, triple));
}
'#' => {
while i + 1 < chars.len() && chars[i + 1] != '\n' {
i += 1;
}
}
'(' | '[' | '{' => {
depth += 1;
buf.push(c);
}
')' | ']' | '}' => {
depth -= 1;
buf.push(c);
}
'\\' if i + 1 < chars.len() && chars[i + 1] == '\n' => {
buf.push(' ');
line += 1;
i += 1;
}
'\n' => {
line += 1;
if depth > 0 {
buf.push('\n');
} else {
if !buf.trim().is_empty() {
statements.push((start_line, std::mem::take(&mut buf)));
} else {
buf.clear();
}
start_line = line;
}
}
_ => {
if buf.trim().is_empty() && !c.is_whitespace() && buf.is_empty() {
start_line = line;
}
buf.push(c);
}
}
i += 1;
}
if !buf.trim().is_empty() {
statements.push((start_line, buf));
}
statements
}
fn split_assignment(stmt: &str) -> Option<(&str, &str)> {
let bytes = stmt.as_bytes();
let mut depth = 0i32;
let mut string_quote: Option<u8> = None;
for i in 0..bytes.len() {
let b = bytes[i];
if let Some(q) = string_quote {
if b == q && (i == 0 || bytes[i - 1] != b'\\') {
string_quote = None;
}
continue;
}
match b {
b'\'' | b'"' => string_quote = Some(b),
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b'=' if depth == 0 => {
let next_eq = bytes.get(i + 1) == Some(&b'=');
let prev = if i > 0 { bytes[i - 1] } else { 0 };
if next_eq || matches!(prev, b'=' | b'!' | b'<' | b'>') {
return None; }
if matches!(
prev,
b'+' | b'-' | b'*' | b'/' | b'%' | b'&' | b'|' | b'^' | b'@'
) {
return None; }
let name = stmt[..i].trim();
let is_identifier = !name.is_empty()
&& name
.chars()
.next()
.map(|c| c.is_ascii_alphabetic() || c == '_')
.unwrap_or(false)
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !is_identifier {
return None;
}
return Some((name, stmt[i + 1..].trim()));
}
_ => {}
}
}
None
}
pub(crate) fn parse_python_literal(src: &str) -> std::result::Result<serde_json::Value, String> {
let chars: Vec<char> = src.chars().collect();
let mut p = PyLiteralParser {
chars,
pos: 0,
saw_comma: false,
};
let value = p.parse_value()?;
p.skip_ws();
if p.pos < p.chars.len() {
return Err("trailing expression".to_string());
}
Ok(value)
}
struct PyLiteralParser {
chars: Vec<char>,
pos: usize,
saw_comma: bool,
}
impl PyLiteralParser {
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn skip_ws(&mut self) {
while matches!(self.peek(), Some(c) if c.is_whitespace()) {
self.pos += 1;
}
}
fn parse_value(&mut self) -> std::result::Result<serde_json::Value, String> {
self.skip_ws();
if let Some(raw) = self.string_prefix() {
let mut s = self.parse_string(raw)?;
while let Some(raw) = {
self.skip_ws();
self.string_prefix()
} {
s.push_str(&self.parse_string(raw)?);
}
return Ok(serde_json::Value::String(s));
}
match self.peek() {
Some('[') => self.parse_sequence('[', ']'),
Some('(') => {
let value = self.parse_sequence('(', ')')?;
match value {
serde_json::Value::Array(items) if items.len() == 1 && !self.saw_comma => {
Ok(items.into_iter().next().unwrap())
}
other => Ok(other),
}
}
Some('{') => self.parse_dict(),
Some(c) if c.is_ascii_digit() || c == '-' || c == '+' || c == '.' => {
self.parse_number()
}
Some(_) => {
if self.eat_keyword("True") {
Ok(serde_json::Value::Bool(true))
} else if self.eat_keyword("False") {
Ok(serde_json::Value::Bool(false))
} else if self.eat_keyword("None") {
Ok(serde_json::Value::Null)
} else {
Err("unsupported expression".to_string())
}
}
None => Err("empty value".to_string()),
}
}
fn eat_keyword(&mut self, kw: &str) -> bool {
let end = self.pos + kw.len();
if end <= self.chars.len() && self.chars[self.pos..end].iter().collect::<String>() == kw {
let boundary = self
.chars
.get(end)
.map(|c| !c.is_ascii_alphanumeric() && *c != '_')
.unwrap_or(true);
if boundary {
self.pos = end;
return true;
}
}
false
}
fn string_prefix(&mut self) -> Option<bool> {
if matches!(self.peek(), Some('\'') | Some('"')) {
return Some(false);
}
for len in [2usize, 1] {
let quote_at = self.pos + len;
if !matches!(self.chars.get(quote_at), Some('\'') | Some('"')) {
continue;
}
let prefix: String = self.chars[self.pos..quote_at]
.iter()
.collect::<String>()
.to_lowercase();
if matches!(prefix.as_str(), "r" | "u" | "b" | "rb" | "br") {
self.pos = quote_at;
return Some(prefix.contains('r'));
}
}
None
}
fn parse_string(&mut self, raw: bool) -> std::result::Result<String, String> {
let quote = self.peek().ok_or("expected string")?;
self.pos += 1;
let triple = self.chars.get(self.pos) == Some("e)
&& self.chars.get(self.pos + 1) == Some("e);
if triple {
self.pos += 2;
}
let mut out = String::new();
loop {
let c = *self
.chars
.get(self.pos)
.ok_or("unterminated string literal")?;
if c == '\\' {
let next = *self
.chars
.get(self.pos + 1)
.ok_or("unterminated escape sequence")?;
if raw {
out.push('\\');
out.push(next);
self.pos += 2;
continue;
}
let translated = match next {
'n' => '\n',
't' => '\t',
'r' => '\r',
'\\' => '\\',
'\'' => '\'',
'"' => '"',
other => {
out.push('\\');
other
}
};
out.push(translated);
self.pos += 2;
continue;
}
if c == quote {
if triple {
if self.chars.get(self.pos + 1) == Some("e)
&& self.chars.get(self.pos + 2) == Some("e)
{
self.pos += 3;
return Ok(out);
}
} else {
self.pos += 1;
return Ok(out);
}
}
out.push(c);
self.pos += 1;
}
}
fn parse_number(&mut self) -> std::result::Result<serde_json::Value, String> {
let start = self.pos;
if matches!(self.peek(), Some('-') | Some('+')) {
self.pos += 1;
}
while matches!(self.peek(), Some(c) if c.is_ascii_digit() || c == '.' || c == '_' || c == 'e' || c == 'E')
{
self.pos += 1;
}
let text: String = self.chars[start..self.pos]
.iter()
.filter(|c| **c != '_')
.collect();
if let Ok(i) = text.parse::<i64>() {
return Ok(serde_json::Value::Number(i.into()));
}
if let Ok(f) = text.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(f) {
return Ok(serde_json::Value::Number(n));
}
}
Err(format!("invalid number '{text}'"))
}
fn parse_sequence(
&mut self,
open: char,
close: char,
) -> std::result::Result<serde_json::Value, String> {
debug_assert_eq!(self.peek(), Some(open));
self.pos += 1;
self.saw_comma = false;
let mut items = Vec::new();
let mut saw_comma = false;
loop {
self.skip_ws();
if self.peek() == Some(close) {
self.pos += 1;
self.saw_comma = saw_comma;
return Ok(serde_json::Value::Array(items));
}
items.push(self.parse_value()?);
self.skip_ws();
match self.peek() {
Some(',') => {
saw_comma = true;
self.pos += 1;
}
Some(c) if c == close => {}
_ => return Err(format!("expected ',' or '{close}'")),
}
}
}
fn parse_dict(&mut self) -> std::result::Result<serde_json::Value, String> {
debug_assert_eq!(self.peek(), Some('{'));
self.pos += 1;
let mut map = serde_json::Map::new();
loop {
self.skip_ws();
if self.peek() == Some('}') {
self.pos += 1;
return Ok(serde_json::Value::Object(map));
}
let key = match self.parse_value()? {
serde_json::Value::String(s) => s,
other => return Err(format!("non-string dict key {other}")),
};
self.skip_ws();
if self.peek() != Some(':') {
return Err("expected ':' in dict".to_string());
}
self.pos += 1;
let value = self.parse_value()?;
map.insert(key, value);
self.skip_ws();
match self.peek() {
Some(',') => {
self.pos += 1;
}
Some('}') => {}
_ => return Err("expected ',' or '}'".to_string()),
}
}
}
}
impl Default for ConfPyConfig {
fn default() -> Self {
Self {
project: None,
version: None,
release: None,
copyright: None,
author: None,
extensions: Vec::new(),
templates_path: vec!["_templates".to_string()],
exclude_patterns: Vec::new(),
include_patterns: vec!["**".to_string()], source_suffix: HashMap::new(),
root_doc: Some("index".to_string()),
language: None,
locale_dirs: vec!["locales".to_string()],
gettext_compact: Some(true),
html_theme: Some("alabaster".to_string()),
html_theme_options: HashMap::new(),
html_title: None,
html_short_title: None,
html_logo: None,
html_favicon: None,
html_css_files: Vec::new(),
html_js_files: Vec::new(),
html_static_path: vec!["_static".to_string()],
html_extra_path: Vec::new(),
html_use_index: Some(true),
html_split_index: Some(false),
html_copy_source: Some(true),
html_show_sourcelink: Some(true),
html_sourcelink_suffix: Some(".txt".to_string()),
html_use_opensearch: None,
html_file_suffix: Some(".html".to_string()),
html_link_suffix: Some(".html".to_string()),
html_show_copyright: Some(true),
html_show_sphinx: Some(true),
html_context: HashMap::new(),
html_output_encoding: Some("utf-8".to_string()),
html_compact_lists: Some(true),
html_secnumber_suffix: Some(". ".to_string()),
html_search_language: None,
html_search_options: HashMap::new(),
html_search_scorer: None,
html_scaled_image_link: Some(true),
html_baseurl: None,
html_codeblock_linenos_style: Some("table".to_string()),
html_math_renderer: Some("mathjax".to_string()),
html_math_renderer_options: HashMap::new(),
latex_engine: Some("pdflatex".to_string()),
latex_documents: Vec::new(),
latex_logo: None,
latex_appendices: Vec::new(),
latex_domain_indices: Some(true),
latex_show_pagerefs: Some(false),
latex_show_urls: Some("no".to_string()),
latex_use_latex_multicolumn: Some(false),
latex_use_xindy: Some(false),
latex_toplevel_sectioning: None,
latex_docclass: HashMap::new(),
latex_additional_files: Vec::new(),
latex_elements: HashMap::new(),
epub_title: None,
epub_author: None,
epub_language: None,
epub_publisher: None,
epub_copyright: None,
epub_identifier: None,
epub_scheme: None,
epub_uid: None,
epub_cover: None,
epub_css_files: Vec::new(),
epub_pre_files: Vec::new(),
epub_post_files: Vec::new(),
epub_exclude_files: Vec::new(),
epub_tocdepth: Some(3),
epub_tocdup: Some(true),
epub_tocscope: Some("default".to_string()),
epub_fix_images: Some(false),
epub_max_image_width: Some(0),
epub_show_urls: Some("inline".to_string()),
epub_use_index: Some(true),
epub_description: None,
epub_contributor: None,
epub_writing_mode: Some("horizontal".to_string()),
extension_configs: HashMap::new(),
needs_sphinx: None,
needs_extensions: HashMap::new(),
manpages_url: None,
nitpicky: Some(false),
nitpick_ignore: Vec::new(),
nitpick_ignore_regex: Vec::new(),
numfig: Some(false),
numfig_format: HashMap::new(),
numfig_secnum_depth: Some(1),
math_number_all: Some(false),
math_eqref_format: None,
math_numfig: Some(true),
tls_verify: Some(true),
tls_cacerts: None,
user_agent: None,
maximum_signature_line_length: None,
python_maximum_signature_line_length: None,
python_trailing_comma_in_multi_line_signatures: None,
python_display_short_literal_types: None,
python_use_unqualified_type_names: None,
toc_object_entries: None,
toc_object_entries_show_parents: None,
source_encoding: None,
confval_type_mismatches: Vec::new(),
add_function_parentheses: None,
add_module_names: None,
strip_signature_backslash: None,
modindex_common_prefix: Vec::new(),
intersphinx_mapping: serde_json::Value::Null,
intersphinx_disabled_reftypes: None,
intersphinx_resolve_self: None,
intersphinx_cache_limit: None,
intersphinx_timeout: None,
gettext_uuid: Some(false),
gettext_location: Some(true),
gettext_auto_build: Some(true),
gettext_additional_targets: Vec::new(),
custom_configs: HashMap::new(),
}
}
}
impl ConfPyConfig {
pub fn to_build_config(&self) -> Result<BuildConfig> {
let mut config = BuildConfig::default();
if let Some(project) = &self.project {
config.project = project.clone();
}
if let Some(version) = &self.version {
config.version = Some(version.clone());
}
if let Some(release) = &self.release {
config.release = Some(release.clone());
}
if let Some(copyright) = &self.copyright {
config.copyright = Some(copyright.clone());
}
if let Some(language) = &self.language {
config.language = Some(language.clone());
}
if let Some(root_doc) = &self.root_doc {
config.root_doc = Some(root_doc.clone());
}
config.extensions = self.extensions.clone();
config.template_dirs = self.templates_path.iter().map(PathBuf::from).collect();
config.static_dirs = self.html_static_path.iter().map(PathBuf::from).collect();
config.html_static_path = self.html_static_path.iter().map(PathBuf::from).collect();
if let Some(html_theme) = &self.html_theme {
config.output.html_theme = html_theme.clone();
config.theme.name = html_theme.clone();
}
if let Some(html_title) = &self.html_title {
config.html_title = Some(html_title.clone());
}
if let Some(html_short_title) = &self.html_short_title {
config.html_short_title = Some(html_short_title.clone());
}
if let Some(html_logo) = &self.html_logo {
config.html_logo = Some(html_logo.clone());
}
if let Some(html_favicon) = &self.html_favicon {
config.html_favicon = Some(html_favicon.clone());
}
config.html_css_files = self.html_css_files.clone();
config.html_js_files = self.html_js_files.clone();
if let Some(html_show_copyright) = self.html_show_copyright {
config.html_show_copyright = Some(html_show_copyright);
}
if let Some(html_show_sphinx) = self.html_show_sphinx {
config.html_show_sphinx = Some(html_show_sphinx);
}
if let Some(html_copy_source) = self.html_copy_source {
config.html_copy_source = Some(html_copy_source);
}
if let Some(html_show_sourcelink) = self.html_show_sourcelink {
config.html_show_sourcelink = Some(html_show_sourcelink);
}
if let Some(html_sourcelink_suffix) = &self.html_sourcelink_suffix {
config.html_sourcelink_suffix = Some(html_sourcelink_suffix.clone());
}
if let Some(html_use_index) = self.html_use_index {
config.html_use_index = Some(html_use_index);
}
if let Some(html_use_opensearch) = &self.html_use_opensearch {
config.html_use_opensearch = Some(!html_use_opensearch.is_empty());
}
if let Some(html_last_updated_fmt) = &self.html_context.get("last_updated") {
if let Some(fmt_str) = html_last_updated_fmt.as_str() {
config.html_last_updated_fmt = Some(fmt_str.to_string());
}
}
config.templates_path = self.templates_path.iter().map(PathBuf::from).collect();
config.include_patterns = if self.include_patterns.is_empty() {
vec!["**".to_string()] } else {
self.include_patterns.clone()
};
config.exclude_patterns = self.exclude_patterns.clone();
config.nitpicky = self.nitpicky.unwrap_or(false);
config.nitpick_ignore = self.nitpick_ignore.clone();
config.nitpick_ignore_regex = self.nitpick_ignore_regex.clone();
config.html_context = self
.html_context
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
config.numfig = self.numfig.unwrap_or(false);
for (figtype, format) in &self.numfig_format {
config.numfig_format.insert(figtype.clone(), format.clone());
}
if let Some(depth) = self.numfig_secnum_depth {
config.numfig_secnum_depth = depth.max(0) as u32;
}
config.maximum_signature_line_length = self.maximum_signature_line_length;
config.python_maximum_signature_line_length = self.python_maximum_signature_line_length;
config.confval_type_mismatches = self.confval_type_mismatches.clone();
if let Some(source_encoding) = &self.source_encoding {
config.source_encoding = source_encoding.clone();
}
if let Some(trailing_comma) = self.python_trailing_comma_in_multi_line_signatures {
config.python_trailing_comma_in_multi_line_signatures = trailing_comma;
}
if let Some(short_literals) = self.python_display_short_literal_types {
config.python_display_short_literal_types = short_literals;
}
if let Some(unqualified) = self.python_use_unqualified_type_names {
config.python_use_unqualified_type_names = unqualified;
}
if let Some(toc_object_entries) = self.toc_object_entries {
config.toc_object_entries = toc_object_entries;
}
if let Some(show_parents) = &self.toc_object_entries_show_parents {
config.toc_object_entries_show_parents = show_parents.clone();
}
if let Some(add_parens) = self.add_function_parentheses {
config.add_function_parentheses = add_parens;
}
if let Some(add_module_names) = self.add_module_names {
config.add_module_names = add_module_names;
}
if let Some(strip_backslash) = self.strip_signature_backslash {
config.strip_signature_backslash = strip_backslash;
}
config.modindex_common_prefix = self.modindex_common_prefix.clone();
let (mapping, errors) = crate::intersphinx::validate_mapping(&self.intersphinx_mapping);
for error in &errors {
log::error!("{error}");
}
if !errors.is_empty() {
return Err(anyhow!(crate::intersphinx::mapping_config_error(
errors.len()
)));
}
config.intersphinx_mapping = mapping;
if let Some(disabled) = &self.intersphinx_disabled_reftypes {
config.intersphinx_disabled_reftypes = disabled.clone();
}
if let Some(resolve_self) = &self.intersphinx_resolve_self {
config.intersphinx_resolve_self = resolve_self.clone();
}
if let Some(limit) = self.intersphinx_cache_limit {
config.intersphinx_cache_limit = limit;
}
config.intersphinx_timeout = self.intersphinx_timeout;
if let Some(tls_verify) = self.tls_verify {
config.tls_verify = tls_verify;
}
config.tls_cacerts = self.tls_cacerts.clone();
config.user_agent = self.user_agent.clone();
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(content: &str) -> PythonConfigParser {
let mut parser = PythonConfigParser::new().unwrap();
parser.parse_statements(content).unwrap();
parser
}
#[test]
fn multiline_list_parses() {
let p = parse("extensions = [\n 'sphinx.ext.autodoc',\n 'sphinx.ext.viewcode',\n]\n");
let v = p.conf_namespace.get("extensions").expect("extensions set");
let items: Vec<&str> = v
.as_array()
.unwrap()
.iter()
.map(|i| i.as_str().unwrap())
.collect();
assert_eq!(items, vec!["sphinx.ext.autodoc", "sphinx.ext.viewcode"]);
assert!(p.warnings().is_empty(), "warnings: {:?}", p.warnings());
}
#[test]
fn multiline_dict_parses() {
let p = parse(
"html_theme_options = {\n 'collapse_navigation': False,\n 'navigation_depth': 4,\n}\n",
);
let v = p
.conf_namespace
.get("html_theme_options")
.expect("dict set");
let obj = v.as_object().unwrap();
assert_eq!(
obj.get("collapse_navigation"),
Some(&serde_json::Value::Bool(false))
);
assert_eq!(
obj.get("navigation_depth").and_then(|n| n.as_i64()),
Some(4)
);
}
#[test]
fn adjacent_string_concat_parses() {
let p = parse("copyright = ('2024, ' 'Team')\n");
assert_eq!(
p.conf_namespace.get("copyright").and_then(|v| v.as_str()),
Some("2024, Team")
);
}
#[test]
fn triple_quoted_string_parses() {
let p = parse("project = \"\"\"Multi\nLine\"\"\"\n");
assert_eq!(
p.conf_namespace.get("project").and_then(|v| v.as_str()),
Some("Multi\nLine")
);
}
#[test]
fn trailing_comment_stripped() {
let p = parse("version = '1.0' # the version\n");
assert_eq!(
p.conf_namespace.get("version").and_then(|v| v.as_str()),
Some("1.0")
);
}
#[test]
fn unsupported_value_warns_and_drops() {
let p = parse("project = os.environ['P']\n");
assert!(!p.conf_namespace.contains_key("project"));
assert_eq!(p.warnings().len(), 1);
assert_eq!(p.warnings()[0].line, 1);
assert!(
p.warnings()[0].message.contains("project"),
"warning names the variable: {}",
p.warnings()[0].message
);
}
#[test]
fn unsupported_statement_warns_but_imports_do_not() {
let p = parse("import os\nfrom pathlib import Path\nsys.path.insert(0, 'x')\n");
assert_eq!(p.warnings().len(), 1, "warnings: {:?}", p.warnings());
assert_eq!(p.warnings()[0].line, 3);
}
#[test]
fn nested_structures_parse() {
let p = parse(
"intersphinx_mapping = {\n 'python': ('https://docs.python.org/3', None),\n}\n",
);
let v = p.conf_namespace.get("intersphinx_mapping").unwrap();
let python = v
.as_object()
.unwrap()
.get("python")
.unwrap()
.as_array()
.unwrap();
assert_eq!(python[0].as_str(), Some("https://docs.python.org/3"));
assert!(python[1].is_null());
}
#[test]
fn numfig_family_reaches_the_build_config() {
let p = parse(
"numfig = True\nnumfig_secnum_depth = 2\n\
numfig_format = {'figure': 'Figure %s', 'table': 'Table {number}'}\n",
);
let config = p
.extract_configuration()
.unwrap()
.to_build_config()
.unwrap();
assert!(config.numfig);
assert_eq!(config.numfig_secnum_depth, 2);
assert_eq!(config.numfig_format["figure"], "Figure %s");
assert_eq!(config.numfig_format["table"], "Table {number}");
assert_eq!(config.numfig_format["section"], "Section %s");
assert_eq!(config.numfig_format["code-block"], "Listing %s");
}
#[test]
fn nitpick_ignore_lists_reach_the_build_config() {
let p = parse(
"nitpicky = True\n\
nitpick_ignore = [('py:func', 'nope'), ('doc', 'missing')]\n\
nitpick_ignore_regex = [(r'std:.*', r'legacy-.*')]\n",
);
let config = p
.extract_configuration()
.unwrap()
.to_build_config()
.unwrap();
assert!(config.nitpicky);
assert_eq!(
config.nitpick_ignore,
vec![
("py:func".to_string(), "nope".to_string()),
("doc".to_string(), "missing".to_string()),
]
);
assert_eq!(
config.nitpick_ignore_regex,
vec![("std:.*".to_string(), "legacy-.*".to_string())]
);
}
#[test]
fn the_object_signature_family_reaches_the_build_config() {
let p = parse(
"maximum_signature_line_length = 88\n\
python_maximum_signature_line_length = 0\n\
python_trailing_comma_in_multi_line_signatures = False\n\
python_display_short_literal_types = True\n\
python_use_unqualified_type_names = True\n\
toc_object_entries = False\n\
toc_object_entries_show_parents = 'all'\n\
add_function_parentheses = False\n\
add_module_names = False\n\
strip_signature_backslash = True\n\
modindex_common_prefix = ['mypkg.', 'other.']\n",
);
let config = p
.extract_configuration()
.unwrap()
.to_build_config()
.unwrap();
assert_eq!(config.maximum_signature_line_length, Some(88));
assert_eq!(
config.python_maximum_signature_line_length,
Some(0),
"an explicit 0 is NOT the same as unset — max_len()'s truthiness \
fall-through depends on carrying it through unchanged"
);
assert!(!config.python_trailing_comma_in_multi_line_signatures);
assert!(config.python_display_short_literal_types);
assert!(config.python_use_unqualified_type_names);
assert!(!config.toc_object_entries);
assert_eq!(config.toc_object_entries_show_parents, "all");
assert!(!config.add_function_parentheses);
assert!(!config.add_module_names);
assert!(config.strip_signature_backslash);
assert_eq!(
config.modindex_common_prefix,
vec!["mypkg.".to_string(), "other.".to_string()]
);
let untouched = parse("project = 'x'\n")
.extract_configuration()
.unwrap()
.to_build_config()
.unwrap();
assert_eq!(untouched.maximum_signature_line_length, None);
assert!(untouched.add_function_parentheses);
assert!(untouched.toc_object_entries);
assert_eq!(untouched.toc_object_entries_show_parents, "domain");
}
#[test]
fn the_object_signature_family_is_not_treated_as_custom_config() {
let p = parse(
"maximum_signature_line_length = 88\n\
python_maximum_signature_line_length = 40\n\
python_trailing_comma_in_multi_line_signatures = False\n\
python_display_short_literal_types = True\n\
python_use_unqualified_type_names = True\n\
toc_object_entries = False\n\
toc_object_entries_show_parents = 'all'\n\
add_function_parentheses = False\n\
add_module_names = False\n\
strip_signature_backslash = True\n\
modindex_common_prefix = ['mypkg.']\n\
nitpick_ignore = [('py:func', 'nope')]\n\
nitpick_ignore_regex = [('py:.*', 'nope.*')]\n\
my_extension_knob = 3\n",
);
let config = p.extract_configuration().unwrap();
assert_eq!(
config.custom_configs.keys().collect::<Vec<_>>(),
vec!["my_extension_knob"],
"only the genuinely unknown key is custom: {:?}",
config.custom_configs
);
}
#[test]
fn raw_and_prefixed_string_literals_parse() {
let p = parse(
r"a = r'back\slash'
b = R'\d+'
c = u'plain'
d = rb'bytes'
e = 'esc\n'
",
);
let ns = |key: &str| p.conf_namespace[key].as_str().unwrap().to_string();
assert_eq!(
ns("a"),
r"back\slash",
"a raw literal keeps its backslashes"
);
assert_eq!(ns("b"), r"\d+");
assert_eq!(ns("c"), "plain");
assert_eq!(ns("d"), "bytes");
assert_eq!(ns("e"), "esc\n", "a plain literal still translates escapes");
}
#[test]
fn the_intersphinx_family_reaches_the_build_config() {
let p = parse(
"intersphinx_mapping = {\n\
'python': ('https://docs.python.org/3', None),\n\
'other': ('https://example.org/', 'local.inv'),\n\
}\n\
intersphinx_disabled_reftypes = ['std:doc', 'std:label']\n\
intersphinx_resolve_self = 'mine'\n\
intersphinx_cache_limit = 0\n\
intersphinx_timeout = 2.5\n\
tls_verify = False\n\
tls_cacerts = '/etc/ca.pem'\n\
user_agent = 'mine/1'\n",
);
let config = p
.extract_configuration()
.unwrap()
.to_build_config()
.expect("a valid mapping must not fail configuration");
assert_eq!(
config.intersphinx_mapping["python"],
("https://docs.python.org/3".to_string(), vec![None])
);
assert_eq!(
config.intersphinx_mapping["other"],
(
"https://example.org/".to_string(),
vec![Some("local.inv".to_string())]
)
);
assert_eq!(
config.intersphinx_disabled_reftypes,
vec!["std:doc".to_string(), "std:label".to_string()]
);
assert_eq!(config.intersphinx_resolve_self, "mine");
assert_eq!(config.intersphinx_cache_limit, 0);
assert_eq!(config.intersphinx_timeout, Some(2.5));
assert!(!config.tls_verify);
assert_eq!(
config.tls_cacerts,
Some(crate::intersphinx::TlsCacerts::Bundle(
"/etc/ca.pem".to_string()
))
);
assert_eq!(config.user_agent, Some("mine/1".to_string()));
}
#[test]
fn tls_cacerts_accepts_the_per_host_mapping_form() {
let p = parse("tls_cacerts = {'docs.example.org': '/etc/example.pem'}\n");
let config = p
.extract_configuration()
.unwrap()
.to_build_config()
.unwrap();
assert_eq!(
config.tls_cacerts,
Some(crate::intersphinx::TlsCacerts::PerHost(
std::collections::BTreeMap::from([(
"docs.example.org".to_string(),
"/etc/example.pem".to_string()
)])
))
);
}
#[test]
fn an_invalid_intersphinx_mapping_stops_the_configuration() {
let p = parse("intersphinx_mapping = {'p': 'https://x/'}\n");
let err = p
.extract_configuration()
.unwrap()
.to_build_config()
.expect_err("a malformed entry must abort");
assert_eq!(
err.to_string(),
"Invalid `intersphinx_mapping` configuration (1 error)."
);
}
#[test]
fn a_conf_py_without_numfig_keeps_the_defaults() {
let p = parse("project = 'Docs'\n");
let config = p
.extract_configuration()
.unwrap()
.to_build_config()
.unwrap();
assert!(!config.numfig);
assert_eq!(config.numfig_secnum_depth, 1);
assert_eq!(config.numfig_format["figure"], "Fig. %s");
}
#[test]
fn a_mistyped_none_default_int_key_in_conf_py_is_recorded_not_coerced() {
let p = parse(
"maximum_signature_line_length = '88'\n\
python_maximum_signature_line_length = 42\n",
);
let config = p.extract_configuration().unwrap();
assert_eq!(config.maximum_signature_line_length, None);
assert_eq!(config.python_maximum_signature_line_length, Some(42));
assert_eq!(
config.confval_type_mismatches,
vec![(
"maximum_signature_line_length".to_string(),
"str".to_string()
)]
);
let build = config.to_build_config().unwrap();
assert_eq!(build.maximum_signature_line_length, None);
assert_eq!(
build.validate(),
vec![
"The config value `maximum_signature_line_length' has type `str'; expected \
`NoneType' or `int'."
.to_string()
]
);
for (literal, type_name) in [
("88.0", "float"),
("True", "bool"),
("[88]", "list"),
("{'a': 1}", "dict"),
] {
let p = parse(&format!(
"python_maximum_signature_line_length = {literal}\n"
));
let config = p.extract_configuration().unwrap();
assert_eq!(
config.python_maximum_signature_line_length, None,
"{literal}"
);
assert_eq!(
config.confval_type_mismatches,
vec![(
"python_maximum_signature_line_length".to_string(),
type_name.to_string()
)],
"{literal}"
);
}
let p = parse("maximum_signature_line_length = None\n");
let config = p.extract_configuration().unwrap();
assert_eq!(config.maximum_signature_line_length, None);
assert!(config.confval_type_mismatches.is_empty());
}
#[test]
fn source_encoding_is_read_from_conf_py() {
let p = parse("source_encoding = 'latin-1'\n");
let config = p.extract_configuration().unwrap();
assert_eq!(config.source_encoding.as_deref(), Some("latin-1"));
assert!(!config.custom_configs.contains_key("source_encoding"));
assert_eq!(config.to_build_config().unwrap().source_encoding, "latin-1");
let p = parse("project = 'x'\n");
let config = p.extract_configuration().unwrap();
assert_eq!(config.source_encoding, None);
assert_eq!(
config.to_build_config().unwrap().source_encoding,
"utf-8-sig"
);
}
}