pyo3_stub_gen/generate/
docstring.rs1use std::fmt;
2
3pub fn normalize_docstring(doc: &str) -> String {
22 let doc = doc.trim();
23 if doc.is_empty() {
24 return String::new();
25 }
26
27 let lines: Vec<&str> = doc.lines().collect();
28
29 let min_indent = lines
31 .iter()
32 .skip(1)
33 .filter(|line| !line.trim().is_empty())
34 .map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
35 .min()
36 .unwrap_or(0);
37
38 let normalized_lines: Vec<String> = lines
40 .iter()
41 .enumerate()
42 .map(|(i, line)| {
43 if i == 0 {
44 line.to_string()
46 } else if line.trim().is_empty() {
47 String::new()
49 } else {
50 if line.len() >= min_indent {
52 line[min_indent..].to_string()
53 } else {
54 line.trim_start().to_string()
55 }
56 }
57 })
58 .collect();
59
60 normalized_lines.join("\n")
61}
62
63pub fn write_docstring(f: &mut impl fmt::Write, doc: &str, indent: &str) -> fmt::Result {
64 let doc = doc.trim();
66 if !doc.is_empty() {
67 let needs_escaping = doc.contains("\"\"\"") || doc.contains(['\0', '\r']);
68 let prefix = if needs_escaping { "" } else { "r" };
69 writeln!(f, "{indent}{prefix}\"\"\"")?;
70 for line in doc.lines() {
71 write!(f, "{indent}")?;
72 if needs_escaping {
73 for ch in line.chars() {
74 match ch {
75 '\\' => f.write_str("\\\\")?,
76 '"' => f.write_str("\\\"")?,
77 '\0' => f.write_str("\\x00")?,
78 '\r' => f.write_str("\\r")?,
79 _ => f.write_char(ch)?,
80 }
81 }
82 } else {
83 f.write_str(line)?;
84 }
85 writeln!(f)?;
86 }
87 writeln!(f, r#"{indent}""""#)?;
88 }
89 Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::generate::Module;
96 use pyo3::{prelude::*, types::PyModule};
97 use std::ffi::CString;
98 use test_case::test_case;
99
100 #[test_case("First line\n\n Indented line"; "multiline")]
101 #[test_case(r#"Example: """triple quoted""" and '''single quoted'''."#; "triple_quotes")]
102 #[test_case(r#"Quoted: \"\"\" and \"""."#; "escaped_quotes")]
103 #[test_case(r#"Paths: C:\new\tools\ and escapes: \n \t \x00 \u1234."#; "literal_backslashes")]
104 #[test_case(r#"Code: """C:\new\tools""" and \n \u1234."#; "quoted_literal_backslashes")]
105 #[test_case("Line ends with \\\nnext line"; "backslash_before_newline")]
106 #[test_case("Trailing backslash \\"; "trailing_backslash")]
107 #[test_case("Triple quotes \"\"\" and trailing backslash \\"; "escaped_trailing_backslash")]
108 #[test_case("Control: \0 and \r inside"; "control_characters")]
109 #[test_case("Unicode: 日本語 🦀 with \"\"\" quotes"; "unicode")]
110 fn docstrings_round_trip_through_python(doc: &str) {
111 let mut source = Module {
112 doc: doc.into(),
113 ..Default::default()
114 }
115 .format_init_py();
116 source.push_str("\ndef documented():\n");
117 write_docstring(&mut source, doc, " ").unwrap();
118 source.push_str(" pass\n");
119
120 Python::initialize();
121 Python::attach(|py| {
122 let source = CString::new(source).unwrap();
123 let module = PyModule::new(py, "docstring_test").unwrap();
124 py.run(&source, Some(&module.dict()), None).unwrap();
125 let inspect = py.import("inspect").unwrap();
126 for object in [
127 module.clone().into_any(),
128 module.getattr("documented").unwrap(),
129 ] {
130 let actual: String = inspect
131 .call_method1("getdoc", (object,))
132 .unwrap()
133 .extract()
134 .unwrap();
135 assert_eq!(actual, doc);
136 }
137 });
138 }
139}