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(()) +}