diff --git a/src/summary.rs b/src/summary.rs index 9d62b6da..d843d90e 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -2661,12 +2661,20 @@ fn docstring(docs: Option<&str>, indent_level: usize, error: Option<&str>) -> St .map(|_| " ") .collect::>() .concat(); + // WIT docs may contain `"""` and/or `'''`. Use a delimiter that does + // not appear in the text; if both do, escape `\` then every `"` so a + // `"""` wrapper remains valid Python. + let (quote, docs) = match (docs.contains(r#"""""#), docs.contains("'''")) { + (true, true) => (r#"""""#, docs.replace('\\', "\\\\").replace('"', "\\\"")), + (true, false) => ("'''", docs), + _ => (r#"""""#, docs), + }; let docs = docs .lines() .map(|line| format!("{indent}{line}\n")) .collect::>() .concat(); - format!(r#""""{newline}{docs}{indent}"""{newline}{indent}"#) + format!("{quote}{newline}{docs}{indent}{quote}{newline}{indent}") } else { String::new() } diff --git a/tests/bindings.rs b/tests/bindings.rs index fed2a1cc..9403ddea 100644 --- a/tests/bindings.rs +++ b/tests/bindings.rs @@ -190,6 +190,82 @@ fn lint_tcp_p3_bindings() -> anyhow::Result<()> { Ok(()) } +#[test] +fn docstring_triple_quotes_are_valid_python() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("example.wit"), + r#"package demo:poc; + +world example { + /// """ + export hello: func(name: string) -> string; + + /// docs containing both """ and ''' + export both: func() -> string; + + /// docs containing both """" and ''' + export four: func() -> string; + + /// docs containing both \""" and ''' + export backslash: func() -> string; +} +"#, + )?; + + cargo::cargo_bin_cmd!("componentize-py") + .current_dir(dir.path()) + .args(["-d", "example.wit", "-w", "example", "bindings", "."]) + .assert() + .success(); + + assert!(predicate::path::is_dir().eval(&dir.path().join("wit_world"))); + + Command::new("python3") + .current_dir(dir.path()) + .args([ + "-c", + r#" +import ast +import sys +from pathlib import Path + +docs_by_name = {} +for path in Path(".").rglob("*.py"): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + doc = ast.get_docstring(node) + if doc: + docs_by_name.setdefault(node.name, []).append(doc) + +hello_docs = docs_by_name.get("hello", []) +if not any('"""' in doc for doc in hello_docs): + sys.stderr.write("hello docstring lost triple double quotes: %r\n" % hello_docs) + sys.exit(1) + +both_docs = docs_by_name.get("both", []) +if not any(("'''" in doc and '"""' in doc) for doc in both_docs): + sys.stderr.write("both docstring lost quote sequences: %r\n" % both_docs) + sys.exit(1) + +four_docs = docs_by_name.get("four", []) +if not any(("'''" in doc and '""""' in doc) for doc in four_docs): + sys.stderr.write("four docstring lost quote sequences: %r\n" % four_docs) + sys.exit(1) + +backslash_docs = docs_by_name.get("backslash", []) +if not any(("'''" in doc and '\\"""' in doc) for doc in backslash_docs): + sys.stderr.write("backslash docstring lost quote sequences: %r\n" % backslash_docs) + sys.exit(1) +"#, + ]) + .assert() + .success(); + + Ok(()) +} + fn generate_bindings(path: &Path, world: &str) -> Result { Ok(cargo::cargo_bin_cmd!("componentize-py") .current_dir(path)