mirror of
https://github.com/openai/codex.git
synced 2026-05-03 02:46:39 +00:00
443 lines
13 KiB
Rust
443 lines
13 KiB
Rust
//! Minimal strict templating for prompt and text assets.
|
|
//!
|
|
//! Supported syntax:
|
|
//! - `{{ name }}` placeholder interpolation
|
|
//! - `{{{{` for a literal `{{`
|
|
//! - `}}}}` for a literal `}}`
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeSet;
|
|
use std::error::Error;
|
|
use std::fmt;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum TemplateParseError {
|
|
EmptyPlaceholder { start: usize },
|
|
NestedPlaceholder { start: usize },
|
|
UnmatchedClosingDelimiter { start: usize },
|
|
UnterminatedPlaceholder { start: usize },
|
|
}
|
|
|
|
impl fmt::Display for TemplateParseError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::EmptyPlaceholder { start } => {
|
|
write!(f, "template placeholder at byte {start} is empty")
|
|
}
|
|
Self::NestedPlaceholder { start } => {
|
|
write!(
|
|
f,
|
|
"template placeholder starting at byte {start} contains a nested `{{`"
|
|
)
|
|
}
|
|
Self::UnmatchedClosingDelimiter { start } => {
|
|
write!(f, "template contains an unmatched `}}` at byte {start}")
|
|
}
|
|
Self::UnterminatedPlaceholder { start } => {
|
|
write!(
|
|
f,
|
|
"template placeholder starting at byte {start} is missing `}}`"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for TemplateParseError {}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum TemplateRenderError {
|
|
DuplicateValue { name: String },
|
|
ExtraValue { name: String },
|
|
MissingValue { name: String },
|
|
}
|
|
|
|
impl fmt::Display for TemplateRenderError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::DuplicateValue { name } => {
|
|
write!(f, "template value `{name}` was provided more than once")
|
|
}
|
|
Self::ExtraValue { name } => {
|
|
write!(f, "template value `{name}` is not used by this template")
|
|
}
|
|
Self::MissingValue { name } => {
|
|
write!(f, "template placeholder `{name}` is missing a value")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for TemplateRenderError {}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum TemplateError {
|
|
Parse(TemplateParseError),
|
|
Render(TemplateRenderError),
|
|
}
|
|
|
|
impl fmt::Display for TemplateError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Parse(err) => err.fmt(f),
|
|
Self::Render(err) => err.fmt(f),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for TemplateError {
|
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
match self {
|
|
Self::Parse(err) => Some(err),
|
|
Self::Render(err) => Some(err),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<TemplateParseError> for TemplateError {
|
|
fn from(value: TemplateParseError) -> Self {
|
|
Self::Parse(value)
|
|
}
|
|
}
|
|
|
|
impl From<TemplateRenderError> for TemplateError {
|
|
fn from(value: TemplateRenderError) -> Self {
|
|
Self::Render(value)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum Segment {
|
|
Literal(String),
|
|
Placeholder(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Template {
|
|
placeholders: BTreeSet<String>,
|
|
segments: Vec<Segment>,
|
|
}
|
|
|
|
impl Template {
|
|
pub fn parse(source: &str) -> Result<Self, TemplateParseError> {
|
|
let mut placeholders = BTreeSet::new();
|
|
let mut segments = Vec::new();
|
|
let mut literal_start = 0usize;
|
|
let mut cursor = 0usize;
|
|
|
|
while cursor < source.len() {
|
|
let rest = &source[cursor..];
|
|
if rest.starts_with("{{{{") {
|
|
push_literal(&mut segments, &source[literal_start..cursor]);
|
|
push_literal(&mut segments, "{{");
|
|
cursor += "{{{{".len();
|
|
literal_start = cursor;
|
|
continue;
|
|
}
|
|
if rest.starts_with("}}}}") {
|
|
push_literal(&mut segments, &source[literal_start..cursor]);
|
|
push_literal(&mut segments, "}}");
|
|
cursor += "}}}}".len();
|
|
literal_start = cursor;
|
|
continue;
|
|
}
|
|
if rest.starts_with("{{") {
|
|
push_literal(&mut segments, &source[literal_start..cursor]);
|
|
let (placeholder, next_cursor) = parse_placeholder(source, cursor)?;
|
|
placeholders.insert(placeholder.clone());
|
|
segments.push(Segment::Placeholder(placeholder));
|
|
cursor = next_cursor;
|
|
literal_start = cursor;
|
|
continue;
|
|
}
|
|
if rest.starts_with("}}") {
|
|
return Err(TemplateParseError::UnmatchedClosingDelimiter { start: cursor });
|
|
}
|
|
|
|
let Some(ch) = rest.chars().next() else {
|
|
break;
|
|
};
|
|
cursor += ch.len_utf8();
|
|
}
|
|
|
|
push_literal(&mut segments, &source[literal_start..]);
|
|
Ok(Self {
|
|
placeholders,
|
|
segments,
|
|
})
|
|
}
|
|
|
|
pub fn placeholders(&self) -> impl ExactSizeIterator<Item = &str> {
|
|
self.placeholders.iter().map(String::as_str)
|
|
}
|
|
|
|
pub fn render<I, K, V>(&self, variables: I) -> Result<String, TemplateRenderError>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
{
|
|
let variables = build_variable_map(variables)?;
|
|
|
|
for placeholder in &self.placeholders {
|
|
if !variables.contains_key(placeholder.as_str()) {
|
|
return Err(TemplateRenderError::MissingValue {
|
|
name: placeholder.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
for name in variables.keys() {
|
|
if !self.placeholders.contains(name.as_str()) {
|
|
return Err(TemplateRenderError::ExtraValue { name: name.clone() });
|
|
}
|
|
}
|
|
|
|
let mut rendered = String::new();
|
|
for segment in &self.segments {
|
|
match segment {
|
|
Segment::Literal(literal) => rendered.push_str(literal),
|
|
Segment::Placeholder(name) => {
|
|
let Some(value) = variables.get(name.as_str()) else {
|
|
return Err(TemplateRenderError::MissingValue { name: name.clone() });
|
|
};
|
|
rendered.push_str(value);
|
|
}
|
|
}
|
|
}
|
|
Ok(rendered)
|
|
}
|
|
}
|
|
|
|
pub fn render<I, K, V>(template: &str, variables: I) -> Result<String, TemplateError>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
{
|
|
Template::parse(template)?
|
|
.render(variables)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
fn push_literal(segments: &mut Vec<Segment>, literal: &str) {
|
|
if literal.is_empty() {
|
|
return;
|
|
}
|
|
|
|
if let Some(Segment::Literal(existing)) = segments.last_mut() {
|
|
existing.push_str(literal);
|
|
} else {
|
|
segments.push(Segment::Literal(literal.to_string()));
|
|
}
|
|
}
|
|
|
|
fn parse_placeholder(source: &str, start: usize) -> Result<(String, usize), TemplateParseError> {
|
|
let placeholder_start = start + "{{".len();
|
|
let mut cursor = placeholder_start;
|
|
|
|
while cursor < source.len() {
|
|
let rest = &source[cursor..];
|
|
if rest.starts_with("{{") {
|
|
return Err(TemplateParseError::NestedPlaceholder { start });
|
|
}
|
|
if rest.starts_with("}}") {
|
|
let placeholder = source[placeholder_start..cursor].trim();
|
|
if placeholder.is_empty() {
|
|
return Err(TemplateParseError::EmptyPlaceholder { start });
|
|
}
|
|
return Ok((placeholder.to_string(), cursor + "}}".len()));
|
|
}
|
|
|
|
let Some(ch) = rest.chars().next() else {
|
|
break;
|
|
};
|
|
cursor += ch.len_utf8();
|
|
}
|
|
|
|
Err(TemplateParseError::UnterminatedPlaceholder { start })
|
|
}
|
|
|
|
fn build_variable_map<I, K, V>(
|
|
variables: I,
|
|
) -> Result<BTreeMap<String, String>, TemplateRenderError>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
{
|
|
let mut map = BTreeMap::new();
|
|
for (name, value) in variables {
|
|
let name = name.as_ref().to_string();
|
|
if map
|
|
.insert(name.clone(), value.as_ref().to_string())
|
|
.is_some()
|
|
{
|
|
return Err(TemplateRenderError::DuplicateValue { name });
|
|
}
|
|
}
|
|
Ok(map)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Template;
|
|
use super::TemplateError;
|
|
use super::TemplateParseError;
|
|
use super::TemplateRenderError;
|
|
use super::render;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn render_replaces_placeholders_with_and_without_whitespace() {
|
|
let rendered = render(
|
|
"Hello, {{ name }}. You are in {{place}}. {{ name }} is repeated.",
|
|
[("name", "Codex"), ("place", "codex-rs")],
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"Hello, Codex. You are in codex-rs. Codex is repeated."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parsed_templates_can_be_reused() {
|
|
let template = Template::parse("{{greeting}}, {{ name }}!").unwrap();
|
|
|
|
assert_eq!(
|
|
template.render([("greeting", "Hello"), ("name", "Codex")]),
|
|
Ok("Hello, Codex!".to_string())
|
|
);
|
|
assert_eq!(
|
|
template.render([("greeting", "Hi"), ("name", "builder")]),
|
|
Ok("Hi, builder!".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn placeholders_are_sorted_and_unique() {
|
|
let template = Template::parse("{{ b }} {{ a }} {{ b }}").unwrap();
|
|
|
|
assert_eq!(template.placeholders().collect::<Vec<_>>(), vec!["a", "b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn render_supports_multiline_templates_and_adjacent_placeholders() {
|
|
let rendered = render(
|
|
"Line 1: {{first}}{{second}}\nLine 2: {{ third }}",
|
|
[("first", "A"), ("second", "B"), ("third", "C")],
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(rendered, "Line 1: AB\nLine 2: C");
|
|
}
|
|
|
|
#[test]
|
|
fn render_supports_literal_delimiter_escapes() {
|
|
let rendered = render(
|
|
"literal open: {{{{, literal close: }}}}, value: {{ name }}",
|
|
[("name", "Codex")],
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"literal open: {{, literal close: }}, value: Codex"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors_when_placeholder_is_empty() {
|
|
let err = Template::parse("Hello, {{ }}.").unwrap_err();
|
|
|
|
assert_eq!(err, TemplateParseError::EmptyPlaceholder { start: 7 });
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors_when_placeholder_is_unterminated() {
|
|
let err = Template::parse("Hello, {{ name.").unwrap_err();
|
|
|
|
assert_eq!(
|
|
err,
|
|
TemplateParseError::UnterminatedPlaceholder { start: 7 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors_when_placeholder_is_nested() {
|
|
let err = Template::parse("Hello, {{ outer {{ inner }} }}.").unwrap_err();
|
|
|
|
assert_eq!(err, TemplateParseError::NestedPlaceholder { start: 7 });
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors_when_closing_delimiter_is_unmatched() {
|
|
let err = Template::parse("Hello, }} world.").unwrap_err();
|
|
|
|
assert_eq!(
|
|
err,
|
|
TemplateParseError::UnmatchedClosingDelimiter { start: 7 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_errors_when_placeholder_is_missing() {
|
|
let template = Template::parse("Hello, {{ name }}.").unwrap();
|
|
|
|
assert_eq!(
|
|
template.render(Vec::<(&str, &str)>::new()),
|
|
Err(TemplateRenderError::MissingValue {
|
|
name: "name".to_string()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_errors_when_extra_value_is_provided() {
|
|
let template = Template::parse("Hello, {{ name }}.").unwrap();
|
|
|
|
assert_eq!(
|
|
template.render([("name", "Codex"), ("unused", "extra")]),
|
|
Err(TemplateRenderError::ExtraValue {
|
|
name: "unused".to_string()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_errors_when_duplicate_value_is_provided() {
|
|
let template = Template::parse("Hello, {{ name }}.").unwrap();
|
|
|
|
assert_eq!(
|
|
template.render([("name", "Codex"), ("name", "other")]),
|
|
Err(TemplateRenderError::DuplicateValue {
|
|
name: "name".to_string()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_function_wraps_parse_errors() {
|
|
let err = render("Hello, }} world.", [("name", "Codex")]).unwrap_err();
|
|
|
|
assert_eq!(
|
|
err,
|
|
TemplateError::Parse(TemplateParseError::UnmatchedClosingDelimiter { start: 7 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_function_wraps_render_errors() {
|
|
let err = render("Hello, {{ name }}.", [("extra", "Codex")]).unwrap_err();
|
|
|
|
assert_eq!(
|
|
err,
|
|
TemplateError::Render(TemplateRenderError::MissingValue {
|
|
name: "name".to_string()
|
|
})
|
|
);
|
|
}
|
|
}
|