use std::{ops::Range, path::Path};
use serde_json::Value;
use crate::{generate::insert_after_marker, output::CliError};
pub const FILE: &str = "cloudflare.config.ts";
pub const BUILD_FILE: &str = "wrangler.config.ts";
pub const ENV_MARKER: &str = "// ocre:env";
pub const TRIGGERS_MARKER: &str = "// ocre:triggers";
pub const EXPORTS_MARKER: &str = "// ocre:exports";
#[derive(Debug, Clone, PartialEq)]
pub struct Call {
pub key: Option<String>,
pub kind: String,
pub args: Range<usize>,
pub values: Option<Vec<Value>>,
}
impl Call {
pub fn field(&self, name: &str) -> Option<&str> {
self.values.as_ref()?.first()?.get(name)?.as_str()
}
pub fn text(&self) -> Option<&str> {
self.values.as_ref()?.first()?.as_str()
}
}
#[derive(Debug)]
pub struct Config {
text: String,
pub name: Option<String>,
pub compatibility_date: Option<String>,
pub env: Vec<Call>,
pub triggers: Vec<Call>,
pub exports: Vec<Call>,
domains: Option<Vec<String>>,
domains_at: Option<Range<usize>>,
name_at: Option<Range<usize>>,
}
impl Config {
pub fn read(root: &Path) -> Result<Self, CliError> {
Self::parse(std::fs::read_to_string(root.join(FILE))?)
}
pub fn parse(text: String) -> Result<Self, CliError> {
let reader = Reader::new(&text);
let worker = reader.worker().ok_or_else(|| {
CliError::new(format!("{FILE} has no `export default defineConfig({{ worker: {{ ... }} }})`")).hint(
"restore the structure `ocre new` writes: `export default defineConfig({ worker: { name, env: { ... }, triggers: [ ... ], exports: { ... } } })`",
)
})?;
let mut config = Self {
name: None,
compatibility_date: None,
env: Vec::new(),
triggers: Vec::new(),
exports: Vec::new(),
domains: None,
domains_at: None,
name_at: None,
text: String::new(),
};
for (key, value) in reader.properties(worker) {
match key.as_str() {
"name" => {
config.name = reader.string(value.clone());
config.name_at = Some(value);
}
"compatibilityDate" => config.compatibility_date = reader.string(value),
"env" => config.env = reader.calls_in_object(value, "bindings")?,
"exports" => config.exports = reader.calls_in_object(value, "exports")?,
"triggers" => config.triggers = reader.calls_in_array(value, "triggers"),
"domains" => {
config.domains = json5::from_str::<Vec<String>>(&text[value.clone()]).ok();
config.domains_at = Some(value);
}
_ => {}
}
}
config.text = text;
Ok(config)
}
pub fn worker_name(&self) -> Result<&str, CliError> {
self.name.as_deref().ok_or_else(|| {
CliError::new(format!("{FILE} has no `worker.name` Ocre can read"))
.hint("write it as a string literal inside `worker: { ... }`: `name: \"<app-name>\",`")
})
}
pub fn binding(&self, key: &str) -> Option<&Call> {
self.env.iter().find(|call| call.key.as_deref() == Some(key))
}
pub fn bindings<'a>(&'a self, kind: &'a str) -> impl Iterator<Item = &'a Call> {
self.env.iter().filter(move |call| call.kind == kind)
}
pub fn triggers<'a>(&'a self, kind: &'a str) -> impl Iterator<Item = &'a Call> {
self.triggers.iter().filter(move |call| call.kind == kind)
}
pub fn export(&self, key: &str) -> Option<&Call> {
self.exports.iter().find(|call| call.key.as_deref() == Some(key))
}
pub fn binding_field(&self, key: &str, field: &str, canonical: &str) -> Result<String, CliError> {
self.binding(key)
.and_then(|call| call.field(field))
.map(str::to_owned)
.ok_or_else(|| unreadable(key, canonical))
}
pub fn database_name(&self) -> Result<String, CliError> {
let name = self.name.as_deref().unwrap_or("<app-name>");
let canonical = format!("DB: bindings.d1({{ name: \"{name}\" }}),");
match self.binding("DB") {
Some(call) if call.kind == "d1" => {
call.field("name").map(str::to_owned).ok_or_else(|| unreadable("DB", &canonical))
}
Some(_) => Err(unreadable("DB", &canonical)),
None => Err(CliError::new(format!("{FILE} has no D1 database bound to `DB`"))
.hint(format!("add `{canonical}` inside `worker.env`"))),
}
}
pub fn vars(&self) -> impl Iterator<Item = (&str, Option<&str>)> {
self.bindings("text").filter_map(|call| Some((call.key.as_deref()?, call.text())))
}
pub fn queue_names(&self) -> Result<Vec<String>, CliError> {
let mut names = Vec::new();
for call in self.bindings("queue") {
let key = call.key.as_deref().unwrap_or("JOBS");
let name = call
.field("name")
.ok_or_else(|| unreadable(key, &format!("{key}: bindings.queue({{ name: \"<queue>\" }}),")))?;
push_once(&mut names, name);
}
for call in self.triggers("queue") {
let name = call.field("name").ok_or_else(|| {
CliError::new(format!("{FILE} has a queue trigger Ocre cannot read"))
.hint("write it as `triggers.queue({ name: \"<queue>\", deadLetterQueue: \"<queue>-failed\" }),`")
})?;
push_once(&mut names, name);
if let Some(dead_letter) = call.field("deadLetterQueue") {
push_once(&mut names, dead_letter);
}
}
Ok(names)
}
pub fn bucket_names(&self) -> Result<Vec<String>, CliError> {
let mut names = Vec::new();
for call in self.bindings("r2") {
let key = call.key.as_deref().unwrap_or("STORAGE");
let name = call
.field("name")
.ok_or_else(|| unreadable(key, &format!("{key}: bindings.r2({{ name: \"<bucket>\" }}),")))?;
push_once(&mut names, name);
}
Ok(names)
}
pub fn kv_without_id(&self) -> Vec<&str> {
self.bindings("kv").filter(|call| call.field("id").is_none()).filter_map(|call| call.key.as_deref()).collect()
}
pub fn crons(&self) -> Result<Vec<String>, CliError> {
self.triggers("scheduled")
.map(|call| {
call.field("schedule").map(str::to_owned).ok_or_else(|| {
CliError::new(format!("{FILE} has a scheduled trigger Ocre cannot read"))
.hint("write it as `triggers.scheduled({ schedule: \"0 3 * * *\" }),`")
})
})
.collect()
}
pub fn insert(&self, marker: &str, entry: &str) -> Result<String, CliError> {
insert_after_marker(&self.text, marker, entry).ok_or_else(|| {
let place = match marker {
ENV_MARKER => "inside `worker.env: { ... }`",
TRIGGERS_MARKER => "inside `worker.triggers: [ ... ]`",
_ => "inside `worker.exports: { ... }`",
};
CliError::new(format!("{FILE} is missing the `{marker}` marker"))
.hint(format!("put `{marker}` on its own line {place}: Ocre adds its entries after it"))
})
}
pub fn set_kv_id(&self, binding: &str, id: &str) -> Result<String, CliError> {
let canonical = format!("{binding}: bindings.kv(),");
let call =
self.binding(binding).filter(|call| call.kind == "kv").ok_or_else(|| unreadable(binding, &canonical))?;
let args = &self.text[call.args.clone()];
let compact: String = args.split_whitespace().collect();
let replacement = match compact.as_str() {
"" | "{}" => format!("{{ id: \"{id}\" }}"),
object if object.starts_with('{') && call.values.is_some() => {
let open = call.args.start + args.find('{').expect("starts with a brace");
return Ok(format!("{} id: \"{id}\",{}", &self.text[..=open], &self.text[open + 1..]));
}
_ => return Err(unreadable(binding, &canonical)),
};
Ok(format!("{}{replacement}{}", &self.text[..call.args.start], &self.text[call.args.end..]))
}
pub fn domains(&self) -> Result<Vec<String>, CliError> {
match (&self.domains, &self.domains_at) {
(Some(domains), _) => Ok(domains.clone()),
(None, None) => Ok(Vec::new()),
(None, Some(_)) => Err(CliError::new(format!("{FILE} has a `domains` entry Ocre cannot read"))
.hint("write it as a list of string literals: `domains: [\"www.example.com\"],`")),
}
}
pub fn with_domains(&self, domains: &[String]) -> Result<String, CliError> {
let list = format!("[{}]", domains.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", "));
if let Some(at) = &self.domains_at {
self.domains()?;
return Ok(format!("{}{list}{}", &self.text[..at.start], &self.text[at.end..]));
}
let Some(name) = &self.name_at else { return Err(self.worker_name().expect_err("no name entry")) };
let line_start = self.text[..name.start].rfind('\n').map_or(0, |at| at + 1);
let indent: String = self.text[line_start..].chars().take_while(|c| c.is_whitespace()).collect();
let line_end = self.text[name.end..].find('\n').map_or(self.text.len(), |at| name.end + at);
Ok(format!(
"{}\n{indent}// Custom domains (`ocre domains add`): `ocre deploy` publishes the Worker on\n\
{indent}// them; Cloudflare creates the DNS record and certificate (zone on this account).\n\
{indent}domains: {list},{}",
&self.text[..line_end],
&self.text[line_end..]
))
}
}
pub fn with_account_id(text: &str, account_id: &str) -> String {
let at = text.find("\tworker: {").expect("the template has a worker entry");
format!("{}\taccountId: \"{account_id}\",\n{}", &text[..at], &text[at..])
}
pub fn assets_directory(root: &Path) -> Option<String> {
let text = std::fs::read_to_string(root.join(BUILD_FILE)).ok()?;
let reader = Reader::new(&text);
let object = reader.define_argument("defineWranglerConfig")?;
reader.properties(object).into_iter().find(|(key, _)| key == "assetsDirectory").and_then(|(_, v)| reader.string(v))
}
fn unreadable(key: &str, canonical: &str) -> CliError {
CliError::new(format!("{FILE} defines `{key}` in a form Ocre cannot read"))
.hint(format!("write it as a literal: `{canonical}`"))
}
fn push_once(names: &mut Vec<String>, name: &str) {
if !names.iter().any(|known| known == name) {
names.push(name.to_owned());
}
}
struct Reader<'a> {
text: &'a str,
code: Vec<u8>,
}
impl<'a> Reader<'a> {
fn new(text: &'a str) -> Self {
Self { text, code: mask(text) }
}
fn worker(&self) -> Option<Range<usize>> {
let config = self.define_argument("defineConfig")?;
let (_, worker) = self.properties(config).into_iter().find(|(key, _)| key == "worker")?;
(self.code.get(worker.start) == Some(&b'{')).then_some(worker)
}
fn define_argument(&self, function: &str) -> Option<Range<usize>> {
let mut from = 0;
while let Some(found) = find(&self.code[from..], function.as_bytes()) {
let at = from + found;
from = at + function.len();
if at > 0 && is_ident(self.code[at - 1]) {
continue;
}
let open = self.skip_space(from);
if self.code.get(open) != Some(&b'(') {
continue;
}
let brace = self.skip_space(open + 1);
if self.code.get(brace) == Some(&b'{') {
return Some(brace..self.closing(brace)? + 1);
}
}
None
}
fn skip_space(&self, mut at: usize) -> usize {
while at < self.code.len() && self.code[at].is_ascii_whitespace() {
at += 1;
}
at
}
fn closing(&self, open: usize) -> Option<usize> {
let mut depth = 0usize;
for (at, byte) in self.code.iter().enumerate().skip(open) {
match byte {
b'{' | b'[' | b'(' => depth += 1,
b'}' | b']' | b')' => {
depth -= 1;
if depth == 0 {
return Some(at);
}
}
_ => {}
}
}
None
}
fn members(&self, outer: Range<usize>) -> Vec<Range<usize>> {
let mut members = Vec::new();
let (mut depth, mut start) = (0usize, outer.start + 1);
for at in outer.start + 1..outer.end - 1 {
match self.code[at] {
b'{' | b'[' | b'(' => depth += 1,
b'}' | b']' | b')' => depth -= 1,
b',' if depth == 0 => {
members.push(start..at);
start = at + 1;
}
_ => {}
}
}
members.push(start..outer.end - 1);
members.into_iter().map(|range| self.trim(range)).filter(|range| !range.is_empty()).collect()
}
fn trim(&self, mut range: Range<usize>) -> Range<usize> {
while range.start < range.end && self.code[range.start].is_ascii_whitespace() {
range.start += 1;
}
while range.end > range.start && self.code[range.end - 1].is_ascii_whitespace() {
range.end -= 1;
}
range
}
fn properties(&self, object: Range<usize>) -> Vec<(String, Range<usize>)> {
self.members(object)
.into_iter()
.filter_map(|member| {
let colon = member.start + self.code[member.clone()].iter().position(|&b| b == b':')?;
let key = self.text[member.start..colon].trim().trim_matches(|c| c == '"' || c == '\'');
Some((key.to_owned(), self.trim(colon + 1..member.end)))
})
.collect()
}
fn string(&self, value: Range<usize>) -> Option<String> {
json5::from_str::<Value>(&self.text[value]).ok()?.as_str().map(str::to_owned)
}
fn call(&self, value: Range<usize>, namespace: &str) -> Option<(String, Range<usize>)> {
let code = &self.code[value.clone()];
let rest = code.strip_prefix(namespace.as_bytes())?;
let dot = value.start + namespace.len() + rest.iter().position(|b| !b.is_ascii_whitespace())?;
if self.code[dot] != b'.' {
return None;
}
let kind_start = self.skip_space(dot + 1);
let kind_len = self.code[kind_start..value.end].iter().take_while(|&&b| is_ident(b)).count();
let open = self.skip_space(kind_start + kind_len);
if kind_len == 0 || self.code.get(open) != Some(&b'(') {
return None;
}
let close = self.closing(open)?;
(close + 1 == value.end).then(|| (self.text[kind_start..kind_start + kind_len].to_owned(), open + 1..close))
}
fn parsed(&self, kind: String, key: Option<String>, args: Range<usize>) -> Call {
let values = json5::from_str::<Vec<Value>>(&format!("[{}]", &self.text[args.clone()])).ok();
Call { key, kind, args, values }
}
fn calls_in_object(&self, object: Range<usize>, namespace: &str) -> Result<Vec<Call>, CliError> {
if self.code.get(object.start) != Some(&b'{') {
return Ok(Vec::new());
}
let mut calls: Vec<Call> = Vec::new();
for (key, value) in self.properties(object) {
let Some((kind, args)) = self.call(value, namespace) else { continue };
if calls.iter().any(|call| call.key.as_deref() == Some(key.as_str())) {
return Err(CliError::new(format!("{FILE} defines `{key}` twice"))
.hint(format!("keep one `{key}: {namespace}.{kind}(...)` entry")));
}
calls.push(self.parsed(kind, Some(key), args));
}
Ok(calls)
}
fn calls_in_array(&self, array: Range<usize>, namespace: &str) -> Vec<Call> {
if self.code.get(array.start) != Some(&b'[') {
return Vec::new();
}
self.members(array)
.into_iter()
.filter_map(|member| self.call(member, namespace))
.map(|(kind, args)| self.parsed(kind, None, args))
.collect()
}
}
fn is_ident(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$'
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|window| window == needle)
}
fn mask(text: &str) -> Vec<u8> {
let bytes = text.as_bytes();
let mut out = bytes.to_vec();
let mut at = 0;
while at < bytes.len() {
match bytes[at] {
b'/' if bytes.get(at + 1) == Some(&b'/') => {
while at < bytes.len() && bytes[at] != b'\n' {
out[at] = b' ';
at += 1;
}
}
b'/' if bytes.get(at + 1) == Some(&b'*') => {
let end = find(&bytes[at + 2..], b"*/").map_or(bytes.len(), |found| at + 2 + found + 2);
for byte in &mut out[at..end] {
if *byte != b'\n' {
*byte = b' ';
}
}
at = end;
}
quote @ (b'"' | b'\'' | b'`') => {
at += 1;
while at < bytes.len() && bytes[at] != quote {
let escaped = bytes[at] == b'\\';
out[at] = b' ';
at += 1;
if escaped && at < bytes.len() {
out[at] = b' ';
at += 1;
}
}
at += 1;
}
_ => at += 1,
}
}
out
}
#[cfg(test)]
#[path = "../tests/config.rs"]
mod tests;