Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
189 changes: 185 additions & 4 deletions Sources/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,24 @@ class Environment {
throw JinjaError.runtime("`namespace` expects either zero arguments or a single object argument")
}
return objectArg
})
}),

// Add strftime_now function to handle date formatting in templates
"strftime_now": FunctionValue(value: { args, _ in
let now = Date()

if args.count > 0, let formatArg = args[0] as? StringValue {
let format = formatArg.value

let result = formatDate(now, withFormat: format)
return StringValue(value: result)
}

// Default format if no arguments
let formatter = DateFormatter()
formatter.dateFormat = "MMMM dd, yyyy"
return StringValue(value: formatter.string(from: now))
}),
]

lazy var tests: [String: (any RuntimeValue...) throws -> Bool] = [
Expand Down Expand Up @@ -442,7 +459,7 @@ class Environment {
)
},
"format": { args, env in
guard let formatString = args[0] as? StringValue else {
guard let format = args[0] as? StringValue else {
throw JinjaError.runtime("format filter requires a format string")
}
// Get the values after the format string
Expand All @@ -461,7 +478,7 @@ class Environment {
return String(describing: arg)
}
// Replace %s with values one by one
var result = formatString.value
var result = format.value
for value in formatValues {
if let range = result.range(of: "%s") {
result.replaceSubrange(range, with: value)
Expand Down Expand Up @@ -1669,8 +1686,22 @@ class Environment {

func lookupVariable(name: String) -> any RuntimeValue {
do {
return try self.resolve(name: name).variables[name] ?? UndefinedValue()
// Look up the variable in the environment chain
let env = try self.resolve(name: name)

// Get the value, handling potential conversions from Swift native types
if let value = env.variables[name] {
// If we have a raw Swift boolean, ensure it's properly converted to BooleanValue
if let boolValue = value.value as? Bool {
return BooleanValue(value: boolValue)
}
return value
}

// Variable doesn't exist
return UndefinedValue()
} catch {
// Cannot resolve variable name
return UndefinedValue()
}
}
Expand Down Expand Up @@ -1773,4 +1804,154 @@ class Environment {
private func doLessThanOrEqual(_ args: [any RuntimeValue]) throws -> Bool {
return try doLessThan(args) || doEqualTo(args)
}

/// Formats a date using strftime-style format specifiers
/// - Parameters:
/// - date: The date to format
/// - format: A strftime-compatible format string
/// - Returns: The formatted date string
static func formatDate(_ date: Date, withFormat format: String) -> String {

let calendar = Calendar.current
let components = calendar.dateComponents(
[
.year, .month, .day, .weekday, .hour, .minute, .second, .nanosecond, .timeZone, .weekOfYear,
.yearForWeekOfYear, .weekdayOrdinal, .quarter,
],
from: date
)

var result = ""
var i = 0

while i < format.count {
let currentIndex = format.index(format.startIndex, offsetBy: i)
let currentChar = format[currentIndex]

if currentChar == "%" && i + 1 < format.count {
let nextIndex = format.index(format.startIndex, offsetBy: i + 1)
let nextChar = format[nextIndex]

// Check for non-padded variant
var isPadded = true
var formatChar = nextChar

if nextChar == "-" && i + 2 < format.count {
isPadded = false
let formatCharIndex = format.index(format.startIndex, offsetBy: i + 2)
formatChar = format[formatCharIndex]
i += 1 // Skip the "-" character
}

switch formatChar {
case "a":
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
result += formatter.string(from: date)
case "A":
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
result += formatter.string(from: date)
case "w":
let weekday = (components.weekday ?? 1) - 1
result += "\(weekday)"
case "d":
let day = components.day ?? 1
result += isPadded ? String(format: "%02d", day) : "\(day)"
case "b":
let formatter = DateFormatter()
formatter.dateFormat = "MMM"
result += formatter.string(from: date)
case "B":
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
result += formatter.string(from: date)
case "m":
let month = components.month ?? 1
result += isPadded ? String(format: "%02d", month) : "\(month)"
case "y":
let year = components.year ?? 0
let shortYear = year % 100
result += isPadded ? String(format: "%02d", shortYear) : "\(shortYear)"
case "Y":
let year = components.year ?? 0
result += "\(year)"
case "H":
let hour = components.hour ?? 0
result += isPadded ? String(format: "%02d", hour) : "\(hour)"
case "I":
var hour12 = (components.hour ?? 0) % 12
if hour12 == 0 { hour12 = 12 }
result += isPadded ? String(format: "%02d", hour12) : "\(hour12)"
case "p":
let hour = components.hour ?? 0
result += hour < 12 ? "AM" : "PM"
case "M":
let minute = components.minute ?? 0
result += isPadded ? String(format: "%02d", minute) : "\(minute)"
case "S":
let second = components.second ?? 0
result += isPadded ? String(format: "%02d", second) : "\(second)"
case "f":
let nano = components.nanosecond ?? 0
let micro = nano / 1000
result += String(format: "%06d", micro)
case "z":
guard let timeZone = components.timeZone else {
result += "+0000"
break
}
let hours = timeZone.secondsFromGMT() / 3600
let minutes = abs(timeZone.secondsFromGMT() % 3600) / 60
let sign = hours >= 0 ? "+" : "-"
result += "\(sign)\(String(format: "%02d", abs(hours)))\(String(format: "%02d", minutes))"
case "Z":
guard let timeZone = components.timeZone else {
result += ""
break
}
result += timeZone.abbreviation() ?? ""
case "j":
let dayOfYear = calendar.ordinality(of: .day, in: .year, for: date) ?? 1
result += isPadded ? String(format: "%03d", dayOfYear) : "\(dayOfYear)"
case "U":
var cal = Calendar(identifier: .gregorian)
cal.firstWeekday = 1 // Sunday
let week = cal.component(.weekOfYear, from: date)
result += String(format: "%02d", week)
case "W":
var cal = Calendar(identifier: .gregorian)
cal.firstWeekday = 2 // Monday
let week = cal.component(.weekOfYear, from: date)
result += String(format: "%02d", week)
case "c":
let formatter = DateFormatter()
formatter.dateStyle = .full
formatter.timeStyle = .full
result += formatter.string(from: date)
case "x":
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .none
result += formatter.string(from: date)
case "X":
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
result += formatter.string(from: date)
case "%":
result += "%"
default:
// Unknown format, just append as is
result += "%\(formatChar)"
}

i += 2 // Skip the % and the format character
} else {
result.append(currentChar)
i += 1
}
}
return result
}
}
42 changes: 36 additions & 6 deletions Sources/Runtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ struct Interpreter {
try environment.setVariable(name: variableName, value: rhs)
} else if let member = node.assignee as? MemberExpression {
let object = try self.evaluate(statement: member.object, environment: environment)
guard var objectValue = object as? ObjectValue else {
guard let objectValue = object as? ObjectValue else {
throw JinjaError.runtime("Cannot assign to member of non-object")
}
guard let property = member.property as? Identifier else {
Expand All @@ -289,6 +289,16 @@ struct Interpreter {
}

func evaluateIf(node: If, environment: Environment) throws -> StringValue {
// Special handling for direct variable checks
if let identifier = node.test as? Identifier {
// For cases like {% if thinking %}, get the variable directly
let value = environment.lookupVariable(name: identifier.value)
// Use the bool method which will return false for undefined values
let testResult = value.bool()
return try self.evaluateBlock(statements: testResult ? node.body : node.alternate, environment: environment)
}

// For non-identifier checks, evaluate normally
let test = try self.evaluate(statement: node.test, environment: environment)
return try self.evaluateBlock(statements: test.bool() ? node.body : node.alternate, environment: environment)
}
Expand Down Expand Up @@ -572,10 +582,30 @@ struct Interpreter {
return BooleanValue(value: true)
}
}
if left is UndefinedValue || right is UndefinedValue {
throw JinjaError.runtime("Cannot perform operation on undefined values")
} else if left is NullValue || right is NullValue {
throw JinjaError.runtime("Cannot perform operation on null values")

// Handle operations with undefined or null values
if left is UndefinedValue || right is UndefinedValue || left is NullValue || right is NullValue {
// Boolean operations return false
if ["and", "or", "==", "!=", ">", "<", ">=", "<=", "in", "not in"].contains(node.operation.value) {
return BooleanValue(value: false)
}

// String concatenation with undefined/null
if node.operation.value == "+" {
if left is StringValue && !(right is UndefinedValue || right is NullValue) {
return left
} else if right is StringValue && !(left is UndefinedValue || left is NullValue) {
return right
}
return StringValue(value: "")
}

// Math operations with undefined/null
if ["-", "*", "/", "%"].contains(node.operation.value) {
return NumericValue(value: 0)
}

return BooleanValue(value: false)
} else if let left = left as? NumericValue, let right = right as? NumericValue {
switch node.operation.value {
case "+":
Expand Down Expand Up @@ -735,7 +765,7 @@ struct Interpreter {
rightValue = String(describing: rightNumeric.value)
} else if let rightBoolean = right as? BooleanValue {
rightValue = String(rightBoolean.value)
} else if right is UndefinedValue {
} else if right is UndefinedValue || right is NullValue {
rightValue = ""
} else {
throw JinjaError.runtime("Unsupported right operand type for string concatenation")
Expand Down
44 changes: 44 additions & 0 deletions Tests/Templates/ChatTemplateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -659,4 +659,48 @@ final class ChatTemplateTests: XCTestCase {
"""
XCTAssertEqual(result, target)
}

func testGraniteWithoutThinking() throws {
let userMessage = [
"role": "user",
"content": "What is 1+1?",
]
let template = try Template(ChatTemplate.granite3_3)
let result = try template.render([
"messages": [userMessage],
"bos_token": "<|begin_of_text|>",
"add_generation_prompt": true,
])
let target = """
<|start_of_role|>system<|end_of_role|>Knowledge Cutoff Date: April 2024.
Today's Date: \(Environment.formatDate(Date(), withFormat: "%B %d, %Y")).
You are Granite, developed by IBM. You are a helpful AI assistant.<|end_of_text|>
<|start_of_role|>user<|end_of_role|>What is 1+1?<|end_of_text|>
<|start_of_role|>assistant<|end_of_role|>
"""
XCTAssertEqual(result, target)
}

func testGraniteWithThinking() throws {
let userMessage = [
"role": "user",
"content": "What is 1+1?",
]
let template = try Template(ChatTemplate.granite3_3)
let result = try template.render([
"messages": [userMessage],
"bos_token": "<|begin_of_text|>",
"add_generation_prompt": true,
"thinking": true,
])
let target = """
<|start_of_role|>system<|end_of_role|>Knowledge Cutoff Date: April 2024.
Today's Date: \(Environment.formatDate(Date(), withFormat: "%B %d, %Y")).
You are Granite, developed by IBM. You are a helpful AI assistant.
Respond to every user query in a comprehensive and detailed way. You can write down your thoughts and reasoning process before responding. In the thought process, engage in a comprehensive cycle of analysis, summarization, exploration, reassessment, reflection, backtracing, and iteration to develop well-considered thinking process. In the response section, based on various attempts, explorations, and reflections from the thoughts section, systematically present the final solution that you deem correct. The response should summarize the thought process. Write your thoughts between <think></think> and write your response between <response></response> for each user query.<|end_of_text|>
<|start_of_role|>user<|end_of_role|>What is 1+1?<|end_of_text|>
<|start_of_role|>assistant<|end_of_role|>
"""
XCTAssertEqual(result, target)
}
}
Loading