use crate::prelude::*;
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
fn reference_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"\b(projects?)((?:\.[A-Za-z_][A-Za-z0-9_]*)*)")
.expect("static regex must compile")
})
}
fn env_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"\benv\.([A-Za-z_][A-Za-z0-9_]*)").expect("static regex must compile")
})
}
fn declared_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"\bdeclared((?:\.[A-Za-z_][A-Za-z0-9_]*)+)")
.expect("static regex must compile")
})
}
fn build_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"\bbuild((?:\.[A-Za-z_][A-Za-z0-9_]*)*)").expect("static regex must compile")
})
}
pub const BUILD_FIELDS: &[&str] = &["root_from_here"];
fn endraw_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN
.get_or_init(|| Regex::new(r"\{%-?\s*endraw\s*-?%\}").expect("static regex must compile"))
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct TemplateReferences {
own_fields: BTreeMap<String, usize>,
aliases: BTreeMap<String, usize>,
peer_fields: BTreeMap<String, BTreeMap<String, usize>>,
iterates_projects: bool,
env_names: BTreeSet<String>,
declared_paths: BTreeMap<String, usize>,
build_fields: BTreeMap<String, usize>,
}
impl TemplateReferences {
pub fn parse(text: &str) -> Result<Self> {
let mut references = Self::default();
for block in blocks(text)? {
let code = mask_strings(block.body);
references.collect(text, block.offset, &code)?;
}
Ok(references)
}
pub fn references_alias(&self, alias: &str) -> bool {
self.aliases.contains_key(alias)
}
pub fn aliases(&self) -> impl Iterator<Item = &str> {
self.aliases.keys().map(String::as_str)
}
pub fn own_fields(&self) -> &BTreeMap<String, usize> {
&self.own_fields
}
pub fn peer_fields(&self, alias: &str) -> Option<&BTreeMap<String, usize>> {
self.peer_fields.get(alias)
}
pub fn iterates_projects(&self) -> bool {
self.iterates_projects
}
pub fn env_names(&self) -> &BTreeSet<String> {
&self.env_names
}
pub fn declared_paths(&self) -> &BTreeMap<String, usize> {
&self.declared_paths
}
pub fn build_fields(&self) -> &BTreeMap<String, usize> {
&self.build_fields
}
fn collect(&mut self, text: &str, offset: usize, code: &str) -> Result<()> {
for capture in env_pattern().captures_iter(code) {
let whole = capture.get(0).expect("group 0 always participates");
if code[..whole.start()].ends_with('.') {
continue;
}
self.env_names.insert(capture[1].to_string());
}
for capture in build_pattern().captures_iter(code) {
let whole = capture.get(0).expect("group 0 always participates");
if code[..whole.start()].ends_with('.') {
continue;
}
let line = line_at(text, offset + whole.start());
let field = capture[1].trim_start_matches('.');
if !BUILD_FIELDS.contains(&field) {
bail!(
"line {line}: '{}' is not a build field (build has: {})",
whole.as_str(),
BUILD_FIELDS.join(", ")
);
}
self.build_fields.entry(field.to_string()).or_insert(line);
}
for capture in declared_pattern().captures_iter(code) {
let whole = capture.get(0).expect("group 0 always participates");
if code[..whole.start()].ends_with('.') {
continue;
}
if code[whole.end()..].starts_with('[') {
bail!(
"line {}: 'declared{}' is indexed dynamically. A declaration \
path must be written literally so it can be checked against \
the workspace before rendering starts",
line_at(text, offset + whole.start()),
&capture[1]
);
}
let path = capture[1].trim_start_matches('.').to_string();
self.declared_paths
.entry(path)
.or_insert_with(|| line_at(text, offset + whole.start()));
}
for capture in reference_pattern().captures_iter(code) {
let whole = capture.get(0).expect("group 0 always participates");
if code[..whole.start()].ends_with('.') {
continue;
}
let line = line_at(text, offset + whole.start());
let root = &capture[1];
let segments: Vec<&str> = capture[2]
.split('.')
.filter(|segment| !segment.is_empty())
.collect();
if code[whole.end()..].starts_with('[') {
bail!(
"line {line}: '{}' is indexed dynamically. Aliases must be \
written literally, as '{root}.my_alias', so the project can \
be resolved before rendering starts",
whole.as_str()
);
}
if root == "project" {
match segments.len() {
0 => {}
1 => {
self.own_fields
.entry(segments[0].to_string())
.or_insert(line);
}
_ => bail!(
"line {line}: '{}' is too deep. Project fields are single \
values, so 'project.{}' has no members",
whole.as_str(),
segments[0]
),
}
continue;
}
match segments.len() {
0 => self.iterates_projects = true,
1 => {
self.aliases.entry(segments[0].to_string()).or_insert(line);
}
2 => {
self.aliases.entry(segments[0].to_string()).or_insert(line);
self.peer_fields
.entry(segments[0].to_string())
.or_default()
.entry(segments[1].to_string())
.or_insert(line);
}
_ => bail!(
"line {line}: '{}' is too deep. Project fields are single \
values, so 'projects.{}.{}' has no members",
whole.as_str(),
segments[0],
segments[1]
),
}
}
Ok(())
}
}
struct Block<'a> {
body: &'a str,
offset: usize,
}
fn blocks(text: &str) -> Result<Vec<Block<'_>>> {
let bytes = text.as_bytes();
let mut blocks = Vec::new();
let mut index = 0usize;
while index + 1 < bytes.len() {
if bytes[index] != b'{' {
index += 1;
continue;
}
match bytes[index + 1] {
b'#' => index = find_close(text, index, index + 2, b'#', false)? + 2,
b'{' => {
let close = find_close(text, index, index + 2, b'}', true)?;
blocks.push(Block {
body: &text[index + 2..close],
offset: index + 2,
});
index = close + 2;
}
b'%' => {
let close = find_close(text, index, index + 2, b'%', true)?;
let body = &text[index + 2..close];
if is_raw_opener(body) {
index = skip_raw(text, index, close + 2)?;
} else {
blocks.push(Block {
body,
offset: index + 2,
});
index = close + 2;
}
}
_ => index += 1,
}
}
Ok(blocks)
}
fn is_raw_opener(body: &str) -> bool {
body.trim()
.trim_start_matches('-')
.trim_end_matches('-')
.trim()
== "raw"
}
fn skip_raw(text: &str, opener: usize, from: usize) -> Result<usize> {
match endraw_pattern().find_at(text, from) {
Some(found) => Ok(found.end()),
None => bail!(
"line {}: a '{{% raw %}}' block is never closed with '{{% endraw %}}'",
line_at(text, opener)
),
}
}
fn find_close(
text: &str,
opener: usize,
from: usize,
first: u8,
skip_strings: bool,
) -> Result<usize> {
let bytes = text.as_bytes();
let mut index = from;
let mut quote: Option<u8> = None;
while index < bytes.len() {
let byte = bytes[index];
if let Some(active) = quote {
if byte == b'\\' {
index += 2;
continue;
}
if byte == active {
quote = None;
}
index += 1;
continue;
}
if skip_strings && (byte == b'"' || byte == b'\'') {
quote = Some(byte);
index += 1;
continue;
}
if byte == first && index + 1 < bytes.len() && bytes[index + 1] == b'}' {
return Ok(index);
}
index += 1;
}
bail!(
"line {}: '{}' is never closed",
line_at(text, opener),
&text[opener..opener + 2]
)
}
fn mask_strings(body: &str) -> String {
let mut masked = body.as_bytes().to_vec();
let mut index = 0usize;
let mut quote: Option<u8> = None;
while index < masked.len() {
let byte = masked[index];
match quote {
Some(active) => {
masked[index] = b' ';
if byte == b'\\' {
if index + 1 < masked.len() {
masked[index + 1] = b' ';
}
index += 2;
continue;
}
if byte == active {
quote = None;
}
index += 1;
}
None => {
if byte == b'"' || byte == b'\'' {
quote = Some(byte);
masked[index] = b' ';
}
index += 1;
}
}
}
String::from_utf8(masked).expect("masking only substitutes ASCII spaces")
}
fn line_at(text: &str, offset: usize) -> usize {
text.as_bytes()[..offset.min(text.len())]
.iter()
.filter(|byte| **byte == b'\n')
.count()
+ 1
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> TemplateReferences {
TemplateReferences::parse(text).unwrap()
}
#[test]
fn a_declaration_path_is_collected() {
let refs = parse("capdag=={{ declared.mg_test.bootstrap.published_capdag }}\n");
assert_eq!(
refs.declared_paths().keys().cloned().collect::<Vec<_>>(),
vec!["mg_test.bootstrap.published_capdag".to_string()]
);
assert!(refs.own_fields().is_empty());
assert_eq!(refs.aliases().count(), 0);
}
#[test]
fn a_declaration_path_remembers_its_line() {
let refs = parse("one\ntwo\n{{ declared.mg_test.targets }}\n");
assert_eq!(refs.declared_paths()["mg_test.targets"], 3);
}
#[test]
fn a_member_named_declared_is_not_this() {
let refs = parse("{% for item in things %}{{ item.declared.thing }}{% endfor %}\n");
assert!(refs.declared_paths().is_empty(), "{:?}", refs.declared_paths());
}
#[test]
fn a_bare_declared_is_not_a_path() {
let refs = parse("{{ declared }}\n");
assert!(refs.declared_paths().is_empty());
}
#[test]
fn a_dynamically_indexed_declaration_is_refused() {
let error = TemplateReferences::parse("{{ declared.mg_test[name] }}\n")
.expect_err("a dynamic index must be refused");
let said = error.to_string();
assert!(said.contains("indexed dynamically"), "{said}");
assert!(said.contains("line 1"), "{said}");
}
#[test]
fn a_quoted_declaration_path_is_text() {
let refs = parse("{{ \"declared.mg_test.bootstrap\" }}\n");
assert!(refs.declared_paths().is_empty(), "{:?}", refs.declared_paths());
}
#[test]
fn text_outside_delimiters_is_not_a_reference() {
let refs =
parse("[project]\nname = \"demo\"\n\n[project.optional-dependencies]\ndev = []\n");
assert!(
refs.own_fields().is_empty(),
"TOML section headers must not be read as template references: {:?}",
refs.own_fields()
);
assert_eq!(refs.aliases().count(), 0);
}
#[test]
fn a_literal_alias_in_prose_is_not_a_reference() {
let refs = parse("# Set projects.mylib.version in your template\nvalue = 1\n");
assert!(!refs.references_alias("mylib"));
}
#[test]
fn references_inside_expressions_are_collected() {
let refs = parse("version = \"{{ projects.mylib.latest_version }}\"\n");
assert!(refs.references_alias("mylib"));
assert_eq!(
refs.peer_fields("mylib")
.unwrap()
.keys()
.collect::<Vec<_>>(),
vec!["latest_version"]
);
}
#[test]
fn references_inside_statements_are_collected() {
let refs = parse("{% if projects.mylib.is_dirty %}dirty{% endif %}");
assert!(refs.references_alias("mylib"));
assert!(refs.peer_fields("mylib").unwrap().contains_key("is_dirty"));
}
#[test]
fn quoted_text_inside_a_block_is_data_not_code() {
let refs = parse("{{ \"projects.ghost.version\" }}");
assert!(
!refs.references_alias("ghost"),
"a string literal must not resolve a project"
);
}
#[test]
fn an_escaped_quote_does_not_end_the_literal_early() {
let refs = parse(r#"{{ "a\"projects.ghost.version" }}"#);
assert!(!refs.references_alias("ghost"));
}
#[test]
fn comments_are_not_code() {
let refs = parse("{# projects.ghost.version #}value");
assert!(!refs.references_alias("ghost"));
}
#[test]
fn raw_blocks_are_literal_content() {
let refs =
parse("{% raw %}{{ projects.ghost.version }}{% endraw %}{{ projects.real.version }}");
assert!(!refs.references_alias("ghost"), "raw content is literal");
assert!(
refs.references_alias("real"),
"code after endraw still counts"
);
}
#[test]
fn whitespace_control_does_not_hide_a_raw_block() {
let refs = parse("{%- raw -%}{{ projects.ghost.version }}{%- endraw -%}");
assert!(!refs.references_alias("ghost"));
}
#[test]
fn a_dotted_owner_is_not_our_context_object() {
let refs = parse("{{ meta.project.version }} {{ other.projects.lib.version }}");
assert!(
refs.own_fields().is_empty(),
"'meta.project' is a member of meta, got: {:?}",
refs.own_fields()
);
assert!(!refs.references_alias("lib"));
}
#[test]
fn a_word_ending_in_project_is_not_a_reference() {
let refs = parse("{{ subproject.version }} {{ myprojects.lib.version }}");
assert!(refs.own_fields().is_empty());
assert!(!refs.references_alias("lib"));
}
#[test]
fn own_and_peer_references_are_kept_apart() {
let refs = parse("{{ project.version }} {{ projects.lib.version }}");
assert!(refs.own_fields().contains_key("version"));
assert!(refs.peer_fields("lib").unwrap().contains_key("version"));
assert!(
!refs.own_fields().contains_key("lib"),
"'projects.lib' must not be read as 'project.lib'"
);
}
#[test]
fn an_alias_named_without_a_field_still_resolves_the_project() {
let refs = parse("{{ projects.lib | json_encode }}");
assert!(refs.references_alias("lib"));
assert!(refs.peer_fields("lib").is_none());
}
#[test]
fn iterating_projects_is_recognised_as_needing_all_of_them() {
let refs = parse("{% for alias, entry in projects %}{{ alias }}{% endfor %}");
assert!(refs.iterates_projects());
assert_eq!(refs.aliases().count(), 0);
}
#[test]
fn naming_a_project_does_not_imply_iterating_all_of_them() {
let refs = parse("{{ projects.lib.version }}");
assert!(!refs.iterates_projects());
}
#[test]
fn lines_are_reported_for_error_messages() {
let refs = parse("line one\nline two\n{{ projects.lib.version }}\n");
assert_eq!(refs.peer_fields("lib").unwrap()["version"], 3);
}
#[test]
fn a_reference_spanning_lines_reports_where_it_starts() {
let refs = parse("{{\n projects.lib.version\n}}");
assert_eq!(refs.peer_fields("lib").unwrap()["version"], 2);
}
#[test]
fn a_closing_brace_inside_a_string_does_not_end_the_block() {
let refs = parse(r#"{{ projects.lib.version | default(value="}}") }}"#);
assert!(refs.peer_fields("lib").unwrap().contains_key("version"));
}
#[test]
fn multibyte_content_does_not_shift_line_numbers() {
let refs = parse("café ☕\n{{ projects.lib.version }}");
assert_eq!(refs.peer_fields("lib").unwrap()["version"], 2);
}
#[test]
fn multibyte_text_inside_a_string_is_masked_without_corrupting_the_block() {
let refs = parse("{{ projects.lib.version }}{{ \"caf\u{e9} projects.ghost.version\" }}");
assert!(refs.references_alias("lib"));
assert!(!refs.references_alias("ghost"));
}
#[test]
fn an_unclosed_expression_is_an_error() {
let error = TemplateReferences::parse("{{ projects.lib.version").unwrap_err();
let message = error.to_string();
assert!(message.contains("never closed"), "got: {message}");
assert!(message.contains("line 1"), "got: {message}");
}
#[test]
fn an_unclosed_raw_block_is_an_error() {
let error = TemplateReferences::parse("{% raw %}{{ x }}").unwrap_err();
assert!(error.to_string().contains("endraw"), "got: {error}");
}
#[test]
fn a_dynamically_indexed_alias_is_an_error() {
let error = TemplateReferences::parse("{{ projects[name].version }}").unwrap_err();
let message = error.to_string();
assert!(message.contains("indexed dynamically"), "got: {message}");
}
#[test]
fn a_path_deeper_than_a_field_is_an_error() {
let error = TemplateReferences::parse("{{ projects.lib.version.major }}").unwrap_err();
assert!(error.to_string().contains("too deep"), "got: {error}");
let error = TemplateReferences::parse("{{ project.version.major }}").unwrap_err();
assert!(error.to_string().contains("too deep"), "got: {error}");
}
#[test]
fn an_unclosed_comment_is_an_error() {
let error = TemplateReferences::parse("{# never closed").unwrap_err();
assert!(error.to_string().contains("never closed"), "got: {error}");
}
#[test]
fn a_lone_brace_is_ordinary_content() {
let refs = parse("{ not a block } {{ projects.lib.version }}");
assert!(refs.references_alias("lib"));
}
}