Skip to content

Commit

Permalink
Support limited JS templates in Brioche.download and other statics (#…
Browse files Browse the repository at this point in the history
…118)

* Add test for `Brioche.download` statics

* Return error if static string includes an escape sequence

* Update projcet analysis with basic support for JS templates

* Update project analysis to allow referencing `project` from the root module
  • Loading branch information
kylewlacy authored Aug 30, 2024
1 parent 0b8cbd6 commit 42ca499
Show file tree
Hide file tree
Showing 2 changed files with 414 additions and 38 deletions.
192 changes: 154 additions & 38 deletions crates/brioche-core/src/project/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ pub async fn analyze_project(vfs: &Vfs, project_path: &Path) -> anyhow::Result<P
})
});

let project_definition = project_export.map(|project_export| {
let project_definition_json_with_context = project_export.map(|project_export| {
let line = contents[..project_export.syntax().text_range().start().into()]
.lines()
.count();
Expand All @@ -157,21 +157,37 @@ pub async fn analyze_project(vfs: &Vfs, project_path: &Path) -> anyhow::Result<P
})
.with_context(|| format!("{file_line}: invalid project export: expected assignment like `export const project = {{ ... }}`"))??;

let json = expression_to_json(&project_export_expr)
let json = expression_to_json(&project_export_expr, &Default::default())
.with_context(|| format!("{file_line}: invalid project export"))?;
let project_definition: ProjectDefinition = serde_json::from_value(json)
.with_context(|| format!("{file_line}: invalid project definition"))?;

anyhow::Ok(project_definition)
anyhow::Ok((json, file_line))
}).transpose()?;
let project_definition_json = project_definition_json_with_context
.clone()
.map(|(json, _)| json);

let project_definition = project_definition_json_with_context
.map(|(json, file_line)| {
let project_definition: ProjectDefinition = serde_json::from_value(json)
.with_context(|| format!("{file_line}: invalid project definition"))?;

anyhow::Ok(project_definition)
})
.transpose()?;
let project_definition = project_definition.unwrap_or_default();

// Support references to `project` for statics in the root module
let root_module_env = project_definition_json
.map(|json| ("project".to_string(), json))
.into_iter()
.collect::<HashMap<_, _>>();

let mut local_modules = HashMap::new();
let root_module = analyze_module(
vfs,
&root_module_path,
project_path,
Some(&module),
&root_module_env,
&mut local_modules,
)
.await?;
Expand All @@ -189,6 +205,7 @@ pub async fn analyze_module(
module_path: &Path,
project_path: &Path,
module: Option<&'async_recursion biome_js_syntax::JsModule>,
env: &HashMap<String, serde_json::Value>,
local_modules: &mut HashMap<BriocheModuleSpecifier, ModuleAnalysis>,
) -> anyhow::Result<BriocheModuleSpecifier> {
let module_path = if module_path == project_path {
Expand Down Expand Up @@ -294,9 +311,18 @@ pub async fn analyze_module(
import_module_path.starts_with(project_path),
"invalid import path: must be within project root",
);
let import_module_specifier =
analyze_module(vfs, &import_module_path, project_path, None, local_modules)
.await?;

// Analyze the imported module, but start with a separate
// environment
let import_module_specifier = analyze_module(
vfs,
&import_module_path,
project_path,
None,
&Default::default(),
local_modules,
)
.await?;
ImportAnalysis::LocalModule(import_module_specifier)
}
BriocheImportSpecifier::External(dependency) => {
Expand All @@ -307,7 +333,7 @@ pub async fn analyze_module(
}

let statics =
find_statics(module, display_location).collect::<anyhow::Result<BTreeSet<_>>>()?;
find_statics(module, env, display_location).collect::<anyhow::Result<BTreeSet<_>>>()?;

let local_module = local_modules
.get_mut(&module_specifier)
Expand Down Expand Up @@ -382,6 +408,7 @@ where

pub fn find_statics<'a, D>(
module: &'a biome_js_syntax::JsModule,
env: &'a HashMap<String, serde_json::Value>,
mut display_location: impl FnMut(usize) -> D + 'a,
) -> impl Iterator<Item = anyhow::Result<StaticQuery>> + 'a
where
Expand Down Expand Up @@ -436,7 +463,7 @@ where
let args = call_expr.arguments()?.args();
let args = args
.iter()
.map(arg_to_string_literal)
.map(|arg| arg_to_string_literal(arg, env))
.map(|arg| {
arg.with_context(|| {
format!("{location}: invalid arg to Brioche.includeFile")
Expand All @@ -445,8 +472,8 @@ where
.collect::<anyhow::Result<Vec<_>>>()?;

// Ensure there's exactly one argument
let include_path = match &*args {
[path] => path.text(),
let path = match &*args {
[path] => path.clone(),
_ => {
anyhow::bail!(
"{location}: Brioche.includeFile() must take exactly one argument",
Expand All @@ -455,15 +482,15 @@ where
};

Ok(Some(StaticQuery::Include(StaticInclude::File {
path: include_path.to_string(),
path,
})))
}
"includeDirectory" => {
// Get the arguments
let args = call_expr.arguments()?.args();
let args = args
.iter()
.map(arg_to_string_literal)
.map(|arg| arg_to_string_literal(arg, env))
.map(|arg| {
arg.with_context(|| {
format!("{location}: invalid arg to Brioche.includeDirectory")
Expand All @@ -472,8 +499,8 @@ where
.collect::<anyhow::Result<Vec<_>>>()?;

// Ensure there's exactly one argument
let include_path = match &*args {
[path] => path.text(),
let path = match &*args {
[path] => path.clone(),
_ => {
anyhow::bail!(
"{location}: Brioche.includeDirectory() must take exactly one argument",
Expand All @@ -482,20 +509,19 @@ where
};

Ok(Some(StaticQuery::Include(StaticInclude::Directory {
path: include_path.to_string(),
path,
})))
}
"glob" => {
// Get the arguments
let args = call_expr.arguments()?.args();
let args = args
.iter()
.map(arg_to_string_literal)
.map(|arg| arg_to_string_literal(arg, env))
.map(|arg| {
let arg = arg.with_context(|| {
arg.with_context(|| {
format!("{location}: invalid arg to Brioche.includeDirectory")
})?;
anyhow::Ok(arg.text().to_string())
})
})
.collect::<anyhow::Result<Vec<_>>>()?;

Expand All @@ -506,7 +532,7 @@ where
let args = call_expr.arguments()?.args();
let args = args
.iter()
.map(arg_to_string_literal)
.map(|arg| arg_to_string_literal(arg, env))
.map(|arg| {
arg.with_context(|| {
format!("{location}: invalid arg to Brioche.download")
Expand All @@ -516,7 +542,7 @@ where

// Ensure there's exactly one argument
let url = match &*args {
[url] => url.text(),
[url] => url.clone(),
_ => {
anyhow::bail!(
"{location}: Brioche.download() must take exactly one argument",
Expand All @@ -540,6 +566,7 @@ where

fn expression_to_json(
expr: &biome_js_syntax::AnyJsExpression,
env: &HashMap<String, serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
use biome_js_syntax::{AnyJsExpression as Expr, AnyJsLiteralExpression as Literal};
match expr {
Expand All @@ -561,6 +588,11 @@ fn expression_to_json(
}
Literal::JsStringLiteralExpression(string) => {
let value = string.inner_string_text().context("invalid string")?;
if value.contains('\\') {
// TODO: Figure out how to properly unescape the string
anyhow::bail!("unsupported escape sequence in string literal");
}

Ok(serde_json::Value::String(value.text().to_string()))
}
_ => {
Expand All @@ -577,7 +609,8 @@ fn expression_to_json(
anyhow::bail!("[{n}]: unsupported array element");
}
};
let element = expression_to_json(&element).with_context(|| format!("[{n}]"))?;
let element =
expression_to_json(&element, env).with_context(|| format!("[{n}]"))?;
values.push(element);
}
Ok(serde_json::Value::Array(values))
Expand All @@ -596,7 +629,7 @@ fn expression_to_json(
let key_expr = computed
.expression()
.context("invalid object member name")?;
let key = expression_to_json(&key_expr)
let key = expression_to_json(&key_expr, env)
.context("invalid object member name")?;
let serde_json::Value::String(key) = key else {
anyhow::bail!("object member name must be a string");
Expand All @@ -613,7 +646,8 @@ fn expression_to_json(
let value = member
.value()
.with_context(|| format!("{key}: syntax error"))?;
let value = expression_to_json(&value).with_context(|| key.to_string())?;
let value =
expression_to_json(&value, env).with_context(|| key.to_string())?;
(key, value)
}
biome_js_syntax::AnyJsObjectMember::JsShorthandPropertyObjectMember(member) => {
Expand All @@ -631,6 +665,94 @@ fn expression_to_json(

Ok(serde_json::Value::Object(values))
}
Expr::JsTemplateExpression(template) => {
anyhow::ensure!(
template.tag().is_none(),
"template literals cannot have tags"
);

let components = template
.elements()
.iter()
.map(|element| {
let value = match element {
biome_js_syntax::AnyJsTemplateElement::JsTemplateChunkElement(chunk) => {
let string = chunk.text();

anyhow::ensure!(!string.contains('\\'), "unsupported escape sequence");

string
}
biome_js_syntax::AnyJsTemplateElement::JsTemplateElement(element) => {
let expr = element
.expression()
.context("invalid template expression")?;
let value = expression_to_json(&expr, env)
.with_context(|| "invalid template expression")?;

let string = value
.as_str()
.context("template component must be a string")?;

string.to_owned()
}
};

anyhow::Ok(value)
})
.collect::<anyhow::Result<Vec<_>>>()?;

Ok(serde_json::Value::String(components.join("")))
}
Expr::JsIdentifierExpression(ident) => {
let name = ident.name().context("invalid identifier")?;
let name = name.text();
let value = env
.get(&name)
.with_context(|| format!("variable {name:?} is not allowed in this context"))?;
Ok(value.clone())
}
Expr::JsStaticMemberExpression(expr) => {
let object = expr.object().context("invalid object reference")?;
let object = expression_to_json(&object, env).context("invalid object reference")?;
let object = object.as_object().context("invalid object reference")?;

let member = expr.member().context("invalid member reference")?;
let member = member.text();

let value = object
.get(&member)
.with_context(|| format!("member {member:?} not found in object"))?;
Ok(value.clone())
}
Expr::JsComputedMemberExpression(expr) => {
let object = expr.object().context("invalid object reference")?;
let object = expression_to_json(&object, env).context("invalid object reference")?;

let member = expr.member().context("invalid member reference")?;
let member = expression_to_json(&member, env).context("invalid member reference")?;

let value = match (object, member) {
(serde_json::Value::Object(object), serde_json::Value::String(member)) => object
.get(&member)
.cloned()
.with_context(|| format!("member {member:?} not found in object"))?,
(serde_json::Value::Array(array), serde_json::Value::Number(member)) => {
let member = member.as_u64().context("invalid array index")?;
let member: usize = member.try_into().context("invalid array index")?;

array
.get(member)
.cloned()
.with_context(|| format!("index {member} out of bounds"))?
}
_ => {
anyhow::bail!("unsupported index expression");
}
};

Ok(value.clone())
}
Expr::JsParenthesizedExpression(_) => todo!(),
Expr::TsAsExpression(_) => todo!(),
Expr::TsNonNullAssertionExpression(_) => todo!(),
Expand All @@ -644,20 +766,14 @@ fn expression_to_json(

fn arg_to_string_literal(
arg: biome_rowan::SyntaxResult<biome_js_syntax::AnyJsCallArgument>,
) -> anyhow::Result<biome_js_syntax::TokenText> {
env: &HashMap<String, serde_json::Value>,
) -> anyhow::Result<String> {
let arg = arg?;
let arg = arg
.as_any_js_expression()
.context("spread arguments are not supported")?;
let arg = arg
.as_any_js_literal_expression()
.context("argument must be a string literal")?;
let arg = arg
.as_js_string_literal_expression()
.context("argument must be a string literal")?;
let arg = arg
.inner_string_text()
.context("invalid string literal argument")?;
let arg = expression_to_json(arg, env)?;
let arg = arg.as_str().context("expected string argument")?;

anyhow::Ok(arg)
anyhow::Ok(arg.to_string())
}
Loading

0 comments on commit 42ca499

Please sign in to comment.