Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
checkpoint
Signed-off-by: lucasew <lucas59356@gmail.com>
  • Loading branch information
lucasew committed Nov 20, 2025
commit bf45f2471e9648646c549d55d4505e7c42734ebe
148 changes: 132 additions & 16 deletions crates/aqua-registry/src/template.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use eyre::{ContextCompat, Result, bail};
use eyre::{bail, ContextCompat, Result};
use heck::ToTitleCase;
use itertools::Itertools;
use std::collections::HashMap;
use std::fmt::Debug;
use versions::Versioning;

type Context = HashMap<String, String>;

Expand Down Expand Up @@ -42,6 +43,10 @@ enum Token<'a> {
Func(&'a str),
Whitespace(&'a str),
Pipe,
LParen,
RParen,
Dot,
Ident(&'a str),
}

fn lex(code: &str) -> Result<Vec<Token<'_>>> {
Expand All @@ -60,6 +65,12 @@ fn lex(code: &str) -> Result<Vec<Token<'_>>> {
} else {
break;
}
} else if code.starts_with("(") {
tokens.push(Token::LParen);
code = &code[1..];
} else if code.starts_with(")") {
tokens.push(Token::RParen);
code = &code[1..];
} else if code.starts_with("|") {
tokens.push(Token::Pipe);
code = &code[1..];
Expand All @@ -72,16 +83,47 @@ fn lex(code: &str) -> Result<Vec<Token<'_>>> {
}
}
} else if code.starts_with(".") {
let end = code.split_whitespace().next().unwrap().len();
tokens.push(Token::Key(&code[1..end]));
code = &code[end..];
} else if code.starts_with("|") {
tokens.push(Token::Pipe);
code = &code[1..];
// Check if this is a property access (after ) or identifier)
let next_char = code.chars().nth(1);
if next_char.is_some_and(|c| c.is_alphabetic()) {
// This could be .Key or .Property
let end = code[1..]
.chars()
.enumerate()
.find(|(_, c)| !c.is_alphanumeric() && *c != '_')
.map(|(i, _)| i + 1)
.unwrap_or(code.len());

// If preceded by RParen, it's a property access
if tokens.last().is_some_and(|t| t.is_r_paren()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Whitespace breaks property access detection in lexer

The lexer checks if the last token is RParen to determine whether a dot starts a property access, but this fails when whitespace appears between the closing parenthesis and the dot. For example, (semver .Version) .Major would incorrectly lex .Major as Key("Major") instead of Dot + Ident("Major") because the last token is Whitespace, not RParen. This causes the parser to miss the property access and return an incorrect result. The check needs to skip whitespace tokens when looking for the preceding RParen.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokens.push(Token::Dot);
tokens.push(Token::Ident(&code[1..end]));
} else {
// Otherwise it's a key reference
tokens.push(Token::Key(&code[1..end]));
}
code = &code[end..];
} else {
tokens.push(Token::Dot);
code = &code[1..];
}
} else {
let func = code.split_whitespace().next().unwrap();
tokens.push(Token::Func(func));
code = &code[func.len()..];
// Check if it's an identifier (alphanumeric starting with letter)
let end = code
.chars()
.enumerate()
.find(|(_, c)| !c.is_alphanumeric() && *c != '_' && *c != '-')
.map(|(i, _)| i)
.unwrap_or(code.len());

if end > 0 {
let token_str = &code[..end];
// Determine if this is a function or identifier based on context
tokens.push(Token::Func(token_str));
code = &code[end..];
} else {
bail!("unexpected character: {}", code.chars().next().unwrap());
}
}
}
Ok(tokens)
Expand All @@ -94,7 +136,7 @@ struct Parser<'a> {
impl Parser<'_> {
fn parse(&self, tokens: Vec<&Token>) -> Result<String> {
let mut s = String::new();
let mut tokens = tokens.iter();
let mut tokens = tokens.iter().peekable();
let expect_whitespace = |t: Option<&&Token>| {
if let Some(token) = t {
if let Token::Whitespace(_) = token {
Expand All @@ -106,11 +148,22 @@ impl Parser<'_> {
bail!("expected whitespace, found: end of input");
}
};
let next_arg = |tokens: &mut std::slice::Iter<&Token>| -> Result<String> {
expect_whitespace(tokens.next())?;
let arg = tokens.next().wrap_err("missing argument")?;
self.parse(vec![arg])
};
let next_arg =
|tokens: &mut std::iter::Peekable<std::slice::Iter<&Token>>| -> Result<String> {
expect_whitespace(tokens.next())?;
let arg = tokens.next().wrap_err("missing argument")?;
match arg {
Token::Key(key) => {
if let Some(val) = self.ctx.get(*key) {
Ok(val.to_string())
} else {
bail!("unable to find key in context: {key}");
}
}
Token::String(str) => Ok(str.to_string()),
_ => bail!("expected key or string, found: {arg:?}"),
}
};

let mut in_pipe = false;
while let Some(token) = tokens.next() {
Expand All @@ -131,6 +184,51 @@ impl Parser<'_> {
}
s = str.to_string()
}
Token::LParen => {
// Handle (semver .Key).Property syntax
let func_token = tokens.next().wrap_err("expected function after (")?;
if let Token::Func(func) = func_token {
if *func == "semver" {
let arg = next_arg(&mut tokens)?;

// Expect )
if !matches!(tokens.next(), Some(Token::RParen)) {
bail!("expected ) after semver argument");
}

// Check for property access
if matches!(tokens.peek(), Some(Token::Dot)) {
tokens.next(); // consume dot
if let Some(Token::Ident(prop)) = tokens.next() {
// Strip 'v' prefix if present before parsing
let clean_version = arg.strip_prefix('v').unwrap_or(&arg);

// Parse version and extract property
let version =
Versioning::new(clean_version).wrap_err_with(|| {
format!("invalid semver version: {arg}")
})?;

s = match *prop {
"Major" => version.nth(0).unwrap_or(0).to_string(),
"Minor" => version.nth(1).unwrap_or(0).to_string(),
"Patch" => version.nth(2).unwrap_or(0).to_string(),
_ => bail!("unknown semver property: {prop}"),
};
} else {
bail!("expected identifier after dot");
}
} else {
// Return the whole version string
s = arg;
}
} else {
bail!("unexpected function in parentheses: {func}");
}
} else {
bail!("expected function after (");
}
}
Token::Func(func) => {
match *func {
"title" | "trimV" => {
Expand Down Expand Up @@ -191,6 +289,9 @@ impl Parser<'_> {
}
in_pipe = true;
}
Token::RParen | Token::Dot | Token::Ident(_) => {
bail!("unexpected token: {token:?}");
}
}
}
if in_pipe {
Expand All @@ -217,6 +318,16 @@ mod tests {
assert_eq!(render(tmpl, &ctx).unwrap(), "Hello, world!");
}

#[test]
fn test_render_semver_maven() {
let tmpl = "https://archive.apache.org/dist/maven/maven-{{(semver .SemVer).Major}}/{{.SemVer}}/binaries/apache-maven-{{.SemVer}}-bin.tar.gz";
let ctx = hashmap(vec![("SemVer", "3.9.11")]);
assert_eq!(
render(tmpl, &ctx).unwrap(),
"https://archive.apache.org/dist/maven/maven-3/3.9.11/binaries/apache-maven-3.9.11-bin.tar.gz"
);
}

macro_rules! parse_tests {
($($name:ident: $value:expr,)*) => {
$(
Expand Down Expand Up @@ -246,6 +357,11 @@ mod tests {
vec![],
),
test_parse_replace: (r#"replace "foo" "bar" "foo-bar""#, "bar-bar", vec![]),
test_parse_semver_major: (r#"(semver .Version).Major"#, "3", vec![("Version", "3.9.11")]),
test_parse_semver_minor: (r#"(semver .Version).Minor"#, "9", vec![("Version", "3.9.11")]),
test_parse_semver_patch: (r#"(semver .Version).Patch"#, "11", vec![("Version", "3.9.11")]),
test_parse_semver_major_v_prefix: (r#"(semver .Version).Major"#, "1", vec![("Version", "v1.2.3")]),
test_parse_semver_no_property: (r#"(semver .Version)"#, "1.2.3", vec![("Version", "1.2.3")]),
);

#[test]
Expand Down