diff --git a/crates/brioche-core/src/project/analyze.rs b/crates/brioche-core/src/project/analyze.rs
index 95b390f..5947579 100644
--- a/crates/brioche-core/src/project/analyze.rs
+++ b/crates/brioche-core/src/project/analyze.rs
@@ -142,7 +142,7 @@ pub async fn analyze_project(vfs: &Vfs, project_path: &Path) -> anyhow::Result
anyhow::Result
>();
+
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?;
@@ -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,
local_modules: &mut HashMap,
) -> anyhow::Result {
let module_path = if module_path == project_path {
@@ -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) => {
@@ -307,7 +333,7 @@ pub async fn analyze_module(
}
let statics =
- find_statics(module, display_location).collect::>>()?;
+ find_statics(module, env, display_location).collect::>>()?;
let local_module = local_modules
.get_mut(&module_specifier)
@@ -382,6 +408,7 @@ where
pub fn find_statics<'a, D>(
module: &'a biome_js_syntax::JsModule,
+ env: &'a HashMap,
mut display_location: impl FnMut(usize) -> D + 'a,
) -> impl Iterator- > + 'a
where
@@ -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")
@@ -445,8 +472,8 @@ where
.collect::>>()?;
// 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",
@@ -455,7 +482,7 @@ where
};
Ok(Some(StaticQuery::Include(StaticInclude::File {
- path: include_path.to_string(),
+ path,
})))
}
"includeDirectory" => {
@@ -463,7 +490,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.includeDirectory")
@@ -472,8 +499,8 @@ where
.collect::>>()?;
// 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",
@@ -482,7 +509,7 @@ where
};
Ok(Some(StaticQuery::Include(StaticInclude::Directory {
- path: include_path.to_string(),
+ path,
})))
}
"glob" => {
@@ -490,12 +517,11 @@ 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| {
- let arg = arg.with_context(|| {
+ arg.with_context(|| {
format!("{location}: invalid arg to Brioche.includeDirectory")
- })?;
- anyhow::Ok(arg.text().to_string())
+ })
})
.collect::>>()?;
@@ -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")
@@ -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",
@@ -540,6 +566,7 @@ where
fn expression_to_json(
expr: &biome_js_syntax::AnyJsExpression,
+ env: &HashMap,
) -> anyhow::Result {
use biome_js_syntax::{AnyJsExpression as Expr, AnyJsLiteralExpression as Literal};
match expr {
@@ -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()))
}
_ => {
@@ -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))
@@ -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");
@@ -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) => {
@@ -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::>>()?;
+
+ 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!(),
@@ -644,20 +766,14 @@ fn expression_to_json(
fn arg_to_string_literal(
arg: biome_rowan::SyntaxResult,
-) -> anyhow::Result {
+ env: &HashMap,
+) -> anyhow::Result {
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())
}
diff --git a/crates/brioche-core/tests/script_project_analyze.rs b/crates/brioche-core/tests/script_project_analyze.rs
index 6107c8f..838769e 100644
--- a/crates/brioche-core/tests/script_project_analyze.rs
+++ b/crates/brioche-core/tests/script_project_analyze.rs
@@ -319,6 +319,119 @@ async fn test_analyze_static_brioche_include() -> anyhow::Result<()> {
Ok(())
}
+#[tokio::test]
+async fn test_analyze_static_brioche_include_escape_error() -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ Brioche.includeFile("\"'\\foo'\"");
+ "#,
+ )
+ .await;
+
+ let result = analyze_project(&brioche.vfs, &project_dir).await;
+
+ // Escape sequences are not currently supported
+ assert_matches!(result, Err(_));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_include_template_simple() -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ Brioche.includeFile(`foo`);
+
+ export function () {
+ return Brioche.includeDirectory(`"bar"`);
+ }
+ "#,
+ )
+ .await;
+
+ let project = analyze_project(&brioche.vfs, &project_dir).await?;
+
+ let root_module = &project.local_modules[&project.root_module];
+
+ assert_eq!(
+ root_module.statics,
+ BTreeSet::from_iter([
+ StaticQuery::Include(StaticInclude::File {
+ path: "foo".to_string()
+ }),
+ StaticQuery::Include(StaticInclude::Directory {
+ path: "\"bar\"".to_string()
+ }),
+ ]),
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_include_template_nested_literal() -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ Brioche.includeFile(`foo/${"bar"}/${`baz`}`);
+ "#,
+ )
+ .await;
+
+ let project = analyze_project(&brioche.vfs, &project_dir).await?;
+
+ let root_module = &project.local_modules[&project.root_module];
+
+ assert_eq!(
+ root_module.statics,
+ BTreeSet::from_iter([StaticQuery::Include(StaticInclude::File {
+ path: "foo/bar/baz".to_string()
+ }),]),
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_include_template_escape_error() -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ Brioche.includeFile(`foo`);
+
+ export function () {
+ return Brioche.includeDirectory(`\$bar`);
+ }
+ "#,
+ )
+ .await;
+
+ let result = analyze_project(&brioche.vfs, &project_dir).await;
+
+ // Escape sequences are not currently supported
+ assert_matches!(result, Err(_));
+
+ Ok(())
+}
+
#[tokio::test]
async fn test_analyze_static_brioche_include_invalid() -> anyhow::Result<()> {
let (brioche, context) = brioche_test_support::brioche_test().await;
@@ -372,3 +485,150 @@ async fn test_analyze_static_brioche_glob() -> anyhow::Result<()> {
Ok(())
}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_download() -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ export function () {
+ return Brioche.download("https://example.com");
+ }
+ "#,
+ )
+ .await;
+
+ let project = analyze_project(&brioche.vfs, &project_dir).await?;
+
+ let root_module = &project.local_modules[&project.root_module];
+
+ assert_eq!(
+ root_module.statics,
+ BTreeSet::from_iter([StaticQuery::Download {
+ url: "https://example.com".parse()?,
+ }]),
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_download_with_project_version() -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ export const project = {
+ version: "1.0.0",
+ }
+
+ export function () {
+ return Brioche.download(`https://example.com/v${project.version}/download.tar.gz`);
+ }
+ "#,
+ )
+ .await;
+
+ let project = analyze_project(&brioche.vfs, &project_dir).await?;
+
+ let root_module = &project.local_modules[&project.root_module];
+
+ assert_eq!(
+ root_module.statics,
+ BTreeSet::from_iter([StaticQuery::Download {
+ url: "https://example.com/v1.0.0/download.tar.gz".parse()?,
+ }]),
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_download_with_project_version_brackets() -> anyhow::Result<()>
+{
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ export const project = {
+ version: "1.0.0",
+ }
+
+ export function () {
+ return Brioche.download(`https://example.com/v${project["version"]}/download.tar.gz`);
+ }
+ "#,
+ )
+ .await;
+
+ let project = analyze_project(&brioche.vfs, &project_dir).await?;
+
+ let root_module = &project.local_modules[&project.root_module];
+
+ assert_eq!(
+ root_module.statics,
+ BTreeSet::from_iter([StaticQuery::Download {
+ url: "https://example.com/v1.0.0/download.tar.gz".parse()?,
+ }]),
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_analyze_static_brioche_download_with_project_version_cross_module_error(
+) -> anyhow::Result<()> {
+ let (brioche, context) = brioche_test_support::brioche_test().await;
+
+ let project_dir = context.mkdir("myproject").await;
+ context
+ .write_file(
+ "myproject/project.bri",
+ r#"
+ import { foo } from "./foo.bri";
+
+ export const project = {
+ version: "1.0.0",
+ }
+
+ export function () {
+ return foo();
+ return Brioche.download(`https://example.com/v${project["version"]}/download.tar.gz`);
+ }
+ "#,
+ )
+ .await;
+ context
+ .write_file(
+ "myproject/foo.bri",
+ r#"
+ // This is a different variable called "project", not the
+ // actual project export
+ export const project = {
+ version: "x",
+ }
+
+ export function foo() {
+ return Brioche.download(`https://example.com/v${project.version}/download.tar.gz`);
+ }
+ "#,
+ )
+ .await;
+
+ let result = analyze_project(&brioche.vfs, &project_dir).await;
+
+ // Only statics in the root module can access the `project` variable
+ assert_matches!(result, Err(_));
+
+ Ok(())
+}