github-actions-expressions-0.0.11/.cargo_vcs_info.json0000644000000001770000000000100164200ustar { "git": { "sha1": "6db45b582f1fe2b358b3553ef2bfa9e16369494f" }, "path_in_vcs": "crates/github-actions-expressions" }github-actions-expressions-0.0.11/Cargo.lock0000644000000205670000000000100144000ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", ] [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "generic-array" version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", ] [[package]] name = "github-actions-expressions" version = "0.0.11" dependencies = [ "anyhow", "itertools", "pest", "pest_derive", "pretty_assertions", "serde_json", "subfeature", ] [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "pest" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" dependencies = [ "memchr", "ucd-trie", ] [[package]] name = "pest_derive" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" dependencies = [ "pest", "pest_generator", ] [[package]] name = "pest_generator" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", "syn", ] [[package]] name = "pest_meta" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" dependencies = [ "pest", "sha2", ] [[package]] name = "pretty_assertions" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", "yansi", ] [[package]] name = "proc-macro2" version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] [[package]] name = "regex" version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", "serde_core", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "subfeature" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46cafa37a988fbf02105b92826360798f64d97378034f09bafabc8fd6ad0a13a" dependencies = [ "memchr", "regex", "serde", ] [[package]] name = "syn" version = "2.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" github-actions-expressions-0.0.11/Cargo.toml0000644000000027660000000000100144240ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2024" name = "github-actions-expressions" version = "0.0.11" authors = ["William Woodruff "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "GitHub Actions expression parser and data types" homepage = "https://docs.zizmor.sh" readme = "README.md" license = "MIT" repository = "https://github.com/zizmorcore/zizmor/tree/main/crates/github-actions-expressions" resolver = "2" [lib] name = "github_actions_expressions" path = "src/lib.rs" [dependencies.anyhow] version = "1.0.100" [dependencies.itertools] version = "0.14.0" [dependencies.pest] version = "2.8.3" [dependencies.pest_derive] version = "2.8.3" [dependencies.serde_json] version = "1.0.145" [dependencies.subfeature] version = "0.0.4" [dev-dependencies.pretty_assertions] version = "1.4.1" [lints.clippy] dbg_macro = "warn" needless_lifetimes = "warn" print_stderr = "warn" print_stdout = "warn" todo = "warn" unimplemented = "warn" unwrap_used = "warn" use_debug = "warn" github-actions-expressions-0.0.11/Cargo.toml.orig000064400000000000000000000011331046102023000200700ustar 00000000000000[package] name = "github-actions-expressions" description = "GitHub Actions expression parser and data types" repository = "https://github.com/zizmorcore/zizmor/tree/main/crates/github-actions-expressions" version = "0.0.11" readme = "README.md" homepage.workspace = true license.workspace = true authors.workspace = true edition.workspace = true [lints] workspace = true [dependencies] anyhow.workspace = true itertools.workspace = true pest.workspace = true pest_derive.workspace = true serde_json.workspace = true subfeature.workspace = true [dev-dependencies] pretty_assertions.workspace = true github-actions-expressions-0.0.11/README.md000064400000000000000000000023061046102023000164630ustar 00000000000000# github-actions-expressions [![zizmor](https://img.shields.io/badge/%F0%9F%8C%88-zizmor-white?labelColor=white)](https://zizmor.sh/) [![CI](https://github.com/zizmorcore/zizmor/actions/workflows/ci.yml/badge.svg)](https://github.com/zizmorcore/zizmor/actions/workflows/ci.yml) [![Crates.io](https://img.shields.io/crates/v/github-actions-expressions)](https://crates.io/crates/github-actions-expressions) [![docs.rs](https://img.shields.io/docsrs/github-actions-expressions)](https://docs.rs/github-actions-expressions) [![GitHub Sponsors](https://img.shields.io/github/sponsors/woodruffw?style=flat&logo=githubsponsors&labelColor=white&color=white)](https://github.com/sponsors/woodruffw) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/PGU3zGZuGG) `github-actions-expressions` is a parser and library for GitHub Actions expressions. Key features: * Faithful parsing of GitHub Actions expressions. * Span-aware AST nodes. * Limited support for constant expression evaluation. See the [documentation] for more details. This library is part of [zizmor]. [documentation]: https://docs.rs/github-actions-expressions [zizmor]: https://zizmor.sh github-actions-expressions-0.0.11/src/call.rs000064400000000000000000001132531046102023000172600ustar 00000000000000//! Representation of function calls in GitHub Actions expressions. use crate::{Evaluation, SpannedExpr}; /// Represents a function in a GitHub Actions expression. /// /// Function names are case-insensitive. #[derive(Debug)] pub struct Function<'src>(pub(crate) &'src str); impl PartialEq for Function<'_> { fn eq(&self, other: &Self) -> bool { self.0.eq_ignore_ascii_case(other.0) } } impl PartialEq for Function<'_> { fn eq(&self, other: &str) -> bool { self.0.eq_ignore_ascii_case(other) } } /// Represents a function call in a GitHub Actions expression. #[derive(Debug, PartialEq)] pub struct Call<'src> { /// The function name, e.g. `foo` in `foo()`. pub func: Function<'src>, /// The function's arguments. pub args: Vec>, } impl<'src> Call<'src> { /// Performs constant evaluation of a GitHub Actions expression /// function call. pub(crate) fn consteval(&self) -> Option { let args = self .args .iter() .map(|arg| arg.consteval()) .collect::>>()?; match &self.func { f if f == "format" => Self::consteval_format(&args), f if f == "contains" => Self::consteval_contains(&args), f if f == "startsWith" => Self::consteval_startswith(&args), f if f == "endsWith" => Self::consteval_endswith(&args), f if f == "toJSON" => Self::consteval_tojson(&args), f if f == "fromJSON" => Self::consteval_fromjson(&args), f if f == "join" => Self::consteval_join(&args), _ => None, } } /// Constant-evaluates a `format(fmtspec, args...)` call. /// /// See: fn consteval_format(args: &[Evaluation]) -> Option { if args.is_empty() { return None; } let template = args[0].sema().to_string(); let mut result = String::new(); let mut index = 0; while index < template.len() { let lbrace = template[index..].find('{').map(|pos| index + pos); let rbrace = template[index..].find('}').map(|pos| index + pos); // Left brace #[allow(clippy::unwrap_used)] if let Some(lbrace_pos) = lbrace && (rbrace.is_none() || rbrace.unwrap() > lbrace_pos) { // Escaped left brace if template.as_bytes().get(lbrace_pos + 1) == Some(&b'{') { result.push_str(&template[index..=lbrace_pos]); index = lbrace_pos + 2; continue; } // Left brace, number, optional format specifiers, right brace if let Some(rbrace_pos) = rbrace && rbrace_pos > lbrace_pos + 1 && let Some(arg_index) = Self::read_arg_index(&template, lbrace_pos + 1) { // Check parameter count if 1 + arg_index > args.len() - 1 { // Invalid format string - too few arguments return None; } // Append the portion before the left brace if lbrace_pos > index { result.push_str(&template[index..lbrace_pos]); } // Append the arg result.push_str(&args[1 + arg_index].sema().to_string()); index = rbrace_pos + 1; continue; } // Invalid format string return None; } // Right brace if let Some(rbrace_pos) = rbrace { #[allow(clippy::unwrap_used)] if lbrace.is_none() || lbrace.unwrap() > rbrace_pos { // Escaped right brace if template.as_bytes().get(rbrace_pos + 1) == Some(&b'}') { result.push_str(&template[index..=rbrace_pos]); index = rbrace_pos + 2; } else { // Invalid format string return None; } } } else { // Last segment result.push_str(&template[index..]); break; } } Some(Evaluation::String(result)) } /// Helper function to read argument index from format string. fn read_arg_index(string: &str, start_index: usize) -> Option { let mut length = 0; let chars: Vec = string.chars().collect(); // Count the number of digits while start_index + length < chars.len() { let next_char = chars[start_index + length]; if next_char.is_ascii_digit() { length += 1; } else { break; } } // Validate at least one digit if length < 1 { return None; } // Parse the number let number_str: String = chars[start_index..start_index + length].iter().collect(); number_str.parse::().ok() } /// Constant-evaluates a `contains(haystack, needle)` call. /// /// See: fn consteval_contains(args: &[Evaluation]) -> Option { if args.len() != 2 { return None; } let search = &args[0]; let item = &args[1]; match search { // For primitive types (strings, numbers, booleans, null), do case-insensitive string search Evaluation::String(_) | Evaluation::Number(_) | Evaluation::Boolean(_) | Evaluation::Null => { let search_str = search.sema().to_string().to_lowercase(); let item_str = item.sema().to_string().to_lowercase(); Some(Evaluation::Boolean(search_str.contains(&item_str))) } // For arrays, check if any element equals the item Evaluation::Array(arr) => { if arr.iter().any(|element| element.sema() == item.sema()) { Some(Evaluation::Boolean(true)) } else { Some(Evaluation::Boolean(false)) } } // `contains(object, ...)` is not defined in the reference implementation Evaluation::Object(_) => None, } } /// Constant-evaluates a `startsWith(string, prefix)` call. /// /// See: fn consteval_startswith(args: &[Evaluation]) -> Option { if args.len() != 2 { return None; } let search_string = &args[0]; let search_value = &args[1]; // Both arguments must be primitive types (not arrays or dictionaries) match (search_string, search_value) { ( Evaluation::String(_) | Evaluation::Number(_) | Evaluation::Boolean(_) | Evaluation::Null, Evaluation::String(_) | Evaluation::Number(_) | Evaluation::Boolean(_) | Evaluation::Null, ) => { // Case-insensitive comparison let string_str = search_string.sema().to_string().to_lowercase(); let prefix_str = search_value.sema().to_string().to_lowercase(); Some(Evaluation::Boolean(string_str.starts_with(&prefix_str))) } // If either argument is not primitive (array or dictionary), return false _ => Some(Evaluation::Boolean(false)), } } /// Constant-evaluates an `endsWith(string, suffix)` call. /// /// See: fn consteval_endswith(args: &[Evaluation]) -> Option { if args.len() != 2 { return None; } let search_string = &args[0]; let search_value = &args[1]; // Both arguments must be primitive types (not arrays or dictionaries) match (search_string, search_value) { ( Evaluation::String(_) | Evaluation::Number(_) | Evaluation::Boolean(_) | Evaluation::Null, Evaluation::String(_) | Evaluation::Number(_) | Evaluation::Boolean(_) | Evaluation::Null, ) => { // Case-insensitive comparison let string_str = search_string.sema().to_string().to_lowercase(); let suffix_str = search_value.sema().to_string().to_lowercase(); Some(Evaluation::Boolean(string_str.ends_with(&suffix_str))) } // If either argument is not primitive (array or dictionary), return false _ => Some(Evaluation::Boolean(false)), } } /// Constant-evaluates a `toJSON(value)` call. /// /// See: fn consteval_tojson(args: &[Evaluation]) -> Option { if args.len() != 1 { return None; } let value = &args[0]; let json_value: serde_json::Value = value.clone().try_into().ok()?; let json_str = serde_json::to_string_pretty(&json_value).ok()?; Some(Evaluation::String(json_str)) } /// Constant-evaluates a `fromJSON(json_string)` call. /// /// See: fn consteval_fromjson(args: &[Evaluation]) -> Option { if args.len() != 1 { return None; } let json_str = args[0].sema().to_string(); // Match reference implementation: error on empty input if json_str.trim().is_empty() { return None; } serde_json::from_str::(&json_str) .ok()? .try_into() .ok() } /// Constant-evaluates a `join(array, optionalSeparator)` call. /// /// See: fn consteval_join(args: &[Evaluation]) -> Option { if args.is_empty() || args.len() > 2 { return None; } let array_or_string = &args[0]; // Get separator (default is comma) let separator = if args.len() > 1 { args[1].sema().to_string() } else { ",".to_string() }; match array_or_string { // For primitive types (strings, numbers, booleans, null), return as string Evaluation::String(_) | Evaluation::Number(_) | Evaluation::Boolean(_) | Evaluation::Null => Some(Evaluation::String(array_or_string.sema().to_string())), // For arrays, join elements with separator Evaluation::Array(arr) => { let joined = arr .iter() .map(|item| item.sema().to_string()) .collect::>() .join(&separator); Some(Evaluation::String(joined)) } // For dictionaries, return empty string (not supported in reference) Evaluation::Object(_) => Some(Evaluation::String("".to_string())), } } } #[cfg(test)] mod tests { use anyhow::Result; use crate::{Expr, call::Call}; #[test] fn test_consteval_fromjson() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Basic primitives ("fromJSON('null')", Evaluation::Null), ("fromJSON('true')", Evaluation::Boolean(true)), ("fromJSON('false')", Evaluation::Boolean(false)), ("fromJSON('42')", Evaluation::Number(42.0)), ("fromJSON('3.14')", Evaluation::Number(3.14)), ("fromJSON('-0')", Evaluation::Number(0.0)), ("fromJSON('0')", Evaluation::Number(0.0)), ( "fromJSON('\"hello\"')", Evaluation::String("hello".to_string()), ), ("fromJSON('\"\"')", Evaluation::String("".to_string())), // Arrays ("fromJSON('[]')", Evaluation::Array(vec![])), ( "fromJSON('[1, 2, 3]')", Evaluation::Array(vec![ Evaluation::Number(1.0), Evaluation::Number(2.0), Evaluation::Number(3.0), ]), ), ( "fromJSON('[\"a\", \"b\", null, true, 123]')", Evaluation::Array(vec![ Evaluation::String("a".to_string()), Evaluation::String("b".to_string()), Evaluation::Null, Evaluation::Boolean(true), Evaluation::Number(123.0), ]), ), // Objects ( "fromJSON('{}')", Evaluation::Object(std::collections::HashMap::new()), ), ( "fromJSON('{\"key\": \"value\"}')", Evaluation::Object({ let mut map = std::collections::HashMap::new(); map.insert("key".to_string(), Evaluation::String("value".to_string())); map }), ), ( "fromJSON('{\"num\": 42, \"bool\": true, \"null\": null}')", Evaluation::Object({ let mut map = std::collections::HashMap::new(); map.insert("num".to_string(), Evaluation::Number(42.0)); map.insert("bool".to_string(), Evaluation::Boolean(true)); map.insert("null".to_string(), Evaluation::Null); map }), ), // Nested structures ( "fromJSON('{\"array\": [1, 2], \"object\": {\"nested\": true}}')", Evaluation::Object({ let mut map = std::collections::HashMap::new(); map.insert( "array".to_string(), Evaluation::Array(vec![Evaluation::Number(1.0), Evaluation::Number(2.0)]), ); let mut nested_map = std::collections::HashMap::new(); nested_map.insert("nested".to_string(), Evaluation::Boolean(true)); map.insert("object".to_string(), Evaluation::Object(nested_map)); map }), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_consteval_fromjson_error_cases() -> Result<()> { let error_cases = &[ "fromJSON('')", // Empty string "fromJSON(' ')", // Whitespace only "fromJSON('invalid')", // Invalid JSON "fromJSON('{invalid}')", // Invalid JSON syntax "fromJSON('[1, 2,]')", // Trailing comma (invalid in strict JSON) ]; for expr_str in error_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval(); assert!( result.is_none(), "Expected None for invalid JSON: {}", expr_str ); } Ok(()) } #[test] fn test_consteval_fromjson_display_format() -> Result<()> { use crate::Evaluation; let test_cases = &[ (Evaluation::Array(vec![Evaluation::Number(1.0)]), "Array"), ( Evaluation::Object(std::collections::HashMap::new()), "Object", ), ]; for (result, expected) in test_cases { assert_eq!(result.sema().to_string(), *expected); } Ok(()) } #[test] fn test_consteval_tojson_fromjson_roundtrip() -> Result<()> { use crate::Evaluation; // Test round-trip conversion for complex structures let test_cases = &[ // Simple array "[1, 2, 3]", // Simple object r#"{"key": "value"}"#, // Mixed array r#"[1, "hello", true, null]"#, // Nested structure r#"{"array": [1, 2], "object": {"nested": true}}"#, ]; for json_str in test_cases { // Parse with fromJSON let from_expr_str = format!("fromJSON('{}')", json_str); let from_expr = Expr::parse(&from_expr_str)?; let parsed = from_expr.consteval().unwrap(); // Convert back with toJSON (using a dummy toJSON call structure) let to_result = Call::consteval_tojson(&[parsed.clone()]).unwrap(); // Parse the result again to compare structure let reparsed_expr_str = format!("fromJSON('{}')", to_result.sema().to_string()); let reparsed_expr = Expr::parse(&reparsed_expr_str)?; let reparsed = reparsed_expr.consteval().unwrap(); // The structure should be preserved (though ordering might differ for objects) match (&parsed, &reparsed) { (Evaluation::Array(a), Evaluation::Array(b)) => assert_eq!(a, b), (Evaluation::Object(_), Evaluation::Object(_)) => { // For dictionaries, we just check that both are dictionaries // since ordering might differ assert!(matches!(parsed, Evaluation::Object(_))); assert!(matches!(reparsed, Evaluation::Object(_))); } (a, b) => assert_eq!(a, b), } } Ok(()) } #[test] fn test_consteval_format() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Basic formatting ( "format('Hello {0}', 'world')", Evaluation::String("Hello world".to_string()), ), ( "format('{0} {1}', 'Hello', 'world')", Evaluation::String("Hello world".to_string()), ), ( "format('Value: {0}', 42)", Evaluation::String("Value: 42".to_string()), ), // Escaped braces ( "format('{{0}}', 'test')", Evaluation::String("{0}".to_string()), ), ( "format('{{Hello}} {0}', 'world')", Evaluation::String("{Hello} world".to_string()), ), ( "format('{0} {{1}}', 'Hello')", Evaluation::String("Hello {1}".to_string()), ), ( "format('}}{{', 'test')", Evaluation::String("}{".to_string()), ), ( "format('{{{{}}}}', 'test')", Evaluation::String("{{}}".to_string()), ), // Multiple arguments ( "format('{0} {1} {2}', 'a', 'b', 'c')", Evaluation::String("a b c".to_string()), ), ( "format('{2} {1} {0}', 'a', 'b', 'c')", Evaluation::String("c b a".to_string()), ), // Repeated arguments ( "format('{0} {0} {0}', 'test')", Evaluation::String("test test test".to_string()), ), // No arguments to replace ( "format('Hello world')", Evaluation::String("Hello world".to_string()), ), // Trailing fragments ("format('abc {{')", Evaluation::String("abc {".to_string())), ("format('abc }}')", Evaluation::String("abc }".to_string())), ( "format('abc {{}}')", Evaluation::String("abc {}".to_string()), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_consteval_format_error_cases() -> Result<()> { let error_cases = &[ // Invalid format strings "format('{0', 'test')", // Missing closing brace "format('0}', 'test')", // Missing opening brace "format('{a}', 'test')", // Non-numeric placeholder "format('{1}', 'test')", // Argument index out of bounds "format('{0} {2}', 'a', 'b')", // Argument index out of bounds "format('{}', 'test')", // Empty braces "format('{-1}', 'test')", // Negative index (invalid) ]; for expr_str in error_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval(); assert!( result.is_none(), "Expected None for invalid format string: {}", expr_str ); } Ok(()) } #[test] fn test_consteval_contains() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Basic string contains (case-insensitive) ( "contains('hello world', 'world')", Evaluation::Boolean(true), ), ( "contains('hello world', 'WORLD')", Evaluation::Boolean(true), ), ( "contains('HELLO WORLD', 'world')", Evaluation::Boolean(true), ), ("contains('hello world', 'foo')", Evaluation::Boolean(false)), ("contains('test', '')", Evaluation::Boolean(true)), // Number to string conversion ("contains('123', '2')", Evaluation::Boolean(true)), ("contains(123, '2')", Evaluation::Boolean(true)), ("contains('hello123', 123)", Evaluation::Boolean(true)), // Boolean to string conversion ("contains('true', true)", Evaluation::Boolean(true)), ("contains('false', false)", Evaluation::Boolean(true)), // Null handling ("contains('null', null)", Evaluation::Boolean(true)), ("contains(null, '')", Evaluation::Boolean(true)), // Array contains - exact matches ( "contains(fromJSON('[1, 2, 3]'), 2)", Evaluation::Boolean(true), ), ( "contains(fromJSON('[1, 2, 3]'), 4)", Evaluation::Boolean(false), ), ( "contains(fromJSON('[\"a\", \"b\", \"c\"]'), 'b')", Evaluation::Boolean(true), ), ( "contains(fromJSON('[\"a\", \"b\", \"c\"]'), 'B')", Evaluation::Boolean(false), // Array search is exact match, not case-insensitive ), ( "contains(fromJSON('[true, false, null]'), true)", Evaluation::Boolean(true), ), ( "contains(fromJSON('[true, false, null]'), null)", Evaluation::Boolean(true), ), // Empty array ( "contains(fromJSON('[]'), 'anything')", Evaluation::Boolean(false), ), // Mixed type array ( "contains(fromJSON('[1, \"hello\", true, null]'), 'hello')", Evaluation::Boolean(true), ), ( "contains(fromJSON('[1, \"hello\", true, null]'), 1)", Evaluation::Boolean(true), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_consteval_join() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Basic array joining with default separator ( "join(fromJSON('[\"a\", \"b\", \"c\"]'))", Evaluation::String("a,b,c".to_string()), ), ( "join(fromJSON('[1, 2, 3]'))", Evaluation::String("1,2,3".to_string()), ), ( "join(fromJSON('[true, false, null]'))", Evaluation::String("true,false,".to_string()), ), // Array joining with custom separator ( "join(fromJSON('[\"a\", \"b\", \"c\"]'), ' ')", Evaluation::String("a b c".to_string()), ), ( "join(fromJSON('[1, 2, 3]'), '-')", Evaluation::String("1-2-3".to_string()), ), ( "join(fromJSON('[\"hello\", \"world\"]'), ' | ')", Evaluation::String("hello | world".to_string()), ), ( "join(fromJSON('[\"a\", \"b\", \"c\"]'), '')", Evaluation::String("abc".to_string()), ), // Empty array ("join(fromJSON('[]'))", Evaluation::String("".to_string())), ( "join(fromJSON('[]'), '-')", Evaluation::String("".to_string()), ), // Single element array ( "join(fromJSON('[\"single\"]'))", Evaluation::String("single".to_string()), ), ( "join(fromJSON('[\"single\"]'), '-')", Evaluation::String("single".to_string()), ), // Primitive values (should return the value as string) ("join('hello')", Evaluation::String("hello".to_string())), ( "join('hello', '-')", Evaluation::String("hello".to_string()), ), ("join(123)", Evaluation::String("123".to_string())), ("join(true)", Evaluation::String("true".to_string())), ("join(null)", Evaluation::String("".to_string())), // Mixed type array ( "join(fromJSON('[1, \"hello\", true, null]'))", Evaluation::String("1,hello,true,".to_string()), ), ( "join(fromJSON('[1, \"hello\", true, null]'), ' | ')", Evaluation::String("1 | hello | true | ".to_string()), ), // Special separator values ( "join(fromJSON('[\"a\", \"b\", \"c\"]'), 123)", Evaluation::String("a123b123c".to_string()), ), ( "join(fromJSON('[\"a\", \"b\", \"c\"]'), true)", Evaluation::String("atruebtruec".to_string()), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_consteval_endswith() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Basic case-insensitive string endsWith ( "endsWith('hello world', 'world')", Evaluation::Boolean(true), ), ( "endsWith('hello world', 'WORLD')", Evaluation::Boolean(true), ), ( "endsWith('HELLO WORLD', 'world')", Evaluation::Boolean(true), ), ( "endsWith('hello world', 'hello')", Evaluation::Boolean(false), ), ("endsWith('hello world', 'foo')", Evaluation::Boolean(false)), // Empty string cases ("endsWith('test', '')", Evaluation::Boolean(true)), ("endsWith('', '')", Evaluation::Boolean(true)), ("endsWith('', 'test')", Evaluation::Boolean(false)), // Number to string conversion ("endsWith('123', '3')", Evaluation::Boolean(true)), ("endsWith(123, '3')", Evaluation::Boolean(true)), ("endsWith('hello123', 123)", Evaluation::Boolean(true)), ("endsWith(12345, 345)", Evaluation::Boolean(true)), // Boolean to string conversion ("endsWith('test true', true)", Evaluation::Boolean(true)), ("endsWith('test false', false)", Evaluation::Boolean(true)), ("endsWith(true, 'ue')", Evaluation::Boolean(true)), // Null handling ("endsWith('test null', null)", Evaluation::Boolean(true)), ("endsWith(null, '')", Evaluation::Boolean(true)), ("endsWith('something', null)", Evaluation::Boolean(true)), // null converts to empty string // Non-primitive types should return false ( "endsWith(fromJSON('[1, 2, 3]'), '3')", Evaluation::Boolean(false), ), ( "endsWith('test', fromJSON('[1, 2, 3]'))", Evaluation::Boolean(false), ), ( "endsWith(fromJSON('{\"key\": \"value\"}'), 'value')", Evaluation::Boolean(false), ), ( "endsWith('test', fromJSON('{\"key\": \"value\"}'))", Evaluation::Boolean(false), ), // Mixed case scenarios ( "endsWith('TestString', 'STRING')", Evaluation::Boolean(true), ), ("endsWith('CamelCase', 'case')", Evaluation::Boolean(true)), // Exact match ("endsWith('exact', 'exact')", Evaluation::Boolean(true)), // Longer suffix than string ( "endsWith('short', 'very long suffix')", Evaluation::Boolean(false), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_consteval_startswith() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Basic case-insensitive string startsWith ( "startsWith('hello world', 'hello')", Evaluation::Boolean(true), ), ( "startsWith('hello world', 'HELLO')", Evaluation::Boolean(true), ), ( "startsWith('HELLO WORLD', 'hello')", Evaluation::Boolean(true), ), ( "startsWith('hello world', 'world')", Evaluation::Boolean(false), ), ( "startsWith('hello world', 'foo')", Evaluation::Boolean(false), ), // Empty string cases ("startsWith('test', '')", Evaluation::Boolean(true)), ("startsWith('', '')", Evaluation::Boolean(true)), ("startsWith('', 'test')", Evaluation::Boolean(false)), // Number to string conversion ("startsWith('123', '1')", Evaluation::Boolean(true)), ("startsWith(123, '1')", Evaluation::Boolean(true)), ("startsWith('123hello', 123)", Evaluation::Boolean(true)), ("startsWith(12345, 123)", Evaluation::Boolean(true)), // Boolean to string conversion ("startsWith('true test', true)", Evaluation::Boolean(true)), ("startsWith('false test', false)", Evaluation::Boolean(true)), ("startsWith(true, 'tr')", Evaluation::Boolean(true)), // Null handling ("startsWith('null test', null)", Evaluation::Boolean(true)), ("startsWith(null, '')", Evaluation::Boolean(true)), ( "startsWith('something', null)", Evaluation::Boolean(true), // null converts to empty string ), // Non-primitive types should return false ( "startsWith(fromJSON('[1, 2, 3]'), '1')", Evaluation::Boolean(false), ), ( "startsWith('test', fromJSON('[1, 2, 3]'))", Evaluation::Boolean(false), ), ( "startsWith(fromJSON('{\"key\": \"value\"}'), 'key')", Evaluation::Boolean(false), ), ( "startsWith('test', fromJSON('{\"key\": \"value\"}'))", Evaluation::Boolean(false), ), // Mixed case scenarios ( "startsWith('TestString', 'TEST')", Evaluation::Boolean(true), ), ( "startsWith('CamelCase', 'camel')", Evaluation::Boolean(true), ), // Exact match ("startsWith('exact', 'exact')", Evaluation::Boolean(true)), // Longer prefix than string ( "startsWith('short', 'very long prefix')", Evaluation::Boolean(false), ), // Partial matches ( "startsWith('prefix_suffix', 'prefix')", Evaluation::Boolean(true), ), ( "startsWith('prefix_suffix', 'suffix')", Evaluation::Boolean(false), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_evaluate_constant_functions() -> Result<()> { use crate::Evaluation; let test_cases = &[ // format function ( "format('{0}', 'hello')", Evaluation::String("hello".to_string()), ), ( "format('{0} {1}', 'hello', 'world')", Evaluation::String("hello world".to_string()), ), ( "format('Value: {0}', 42)", Evaluation::String("Value: 42".to_string()), ), // contains function ( "contains('hello world', 'world')", Evaluation::Boolean(true), ), ("contains('hello world', 'foo')", Evaluation::Boolean(false)), ("contains('test', '')", Evaluation::Boolean(true)), // startsWith function ( "startsWith('hello world', 'hello')", Evaluation::Boolean(true), ), ( "startsWith('hello world', 'world')", Evaluation::Boolean(false), ), ("startsWith('test', '')", Evaluation::Boolean(true)), // endsWith function ( "endsWith('hello world', 'world')", Evaluation::Boolean(true), ), ( "endsWith('hello world', 'hello')", Evaluation::Boolean(false), ), ("endsWith('test', '')", Evaluation::Boolean(true)), // toJSON function ( "toJSON('hello')", Evaluation::String("\"hello\"".to_string()), ), ("toJSON(42)", Evaluation::String("42".to_string())), ("toJSON(true)", Evaluation::String("true".to_string())), ("toJSON(null)", Evaluation::String("null".to_string())), // fromJSON function - primitives ( "fromJSON('\"hello\"')", Evaluation::String("hello".to_string()), ), ("fromJSON('42')", Evaluation::Number(42.0)), ("fromJSON('true')", Evaluation::Boolean(true)), ("fromJSON('null')", Evaluation::Null), // fromJSON function - arrays and objects ( "fromJSON('[1, 2, 3]')", Evaluation::Array(vec![ Evaluation::Number(1.0), Evaluation::Number(2.0), Evaluation::Number(3.0), ]), ), ( "fromJSON('{\"key\": \"value\"}')", Evaluation::Object({ let mut map = std::collections::HashMap::new(); map.insert("key".to_string(), Evaluation::String("value".to_string())); map }), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!( result, *expected, "Failed for expression: {} {result:?}", expr_str ); } Ok(()) } } github-actions-expressions-0.0.11/src/context.rs000064400000000000000000000431511046102023000200300ustar 00000000000000//! Parsing and matching APIs for GitHub Actions expressions //! contexts (e.g. `github.event.name`). use crate::Literal; use super::{Expr, SpannedExpr}; /// Represents a context in a GitHub Actions expression. /// /// These typically look something like `github.actor` or `inputs.foo`, /// although they can also be a "call" context like `fromJSON(...).foo.bar`, /// i.e. where the head of the context is a function call rather than an /// identifier. #[derive(Debug, PartialEq)] pub struct Context<'src> { /// The individual parts of the context. pub parts: Vec>, } impl<'src> Context<'src> { pub(crate) fn new(parts: impl Into>>) -> Self { Self { parts: parts.into(), } } /// Returns whether the context matches the given pattern exactly. pub fn matches(&self, pattern: impl TryInto>) -> bool { let Ok(pattern) = pattern.try_into() else { return false; }; pattern.matches(self) } /// Returns whether the context is a child of the given pattern. /// /// A context is considered its own child, i.e. `foo.bar` is a child of /// `foo.bar`. pub fn child_of(&self, parent: impl TryInto>) -> bool { let Ok(parent) = parent.try_into() else { return false; }; parent.parent_of(self) } /// Return this context's "single tail," if it has one. /// /// This is useful primarily for contexts under `env` and `inputs`, /// where we expect only a single tail part, e.g. `env.FOO` or /// `inputs['bar']`. /// /// Returns `None` if the context has more than one tail part, /// or if the context's head part is not an identifier. pub fn single_tail(&self) -> Option<&str> { if self.parts.len() != 2 || !matches!(*self.parts[0], Expr::Identifier(_)) { return None; } match &self.parts[1].inner { Expr::Identifier(ident) => Some(ident.as_str()), Expr::Index(idx) => match &idx.inner { Expr::Literal(Literal::String(idx)) => Some(idx), _ => None, }, _ => None, } } /// Returns the "pattern equivalent" of this context. /// /// This is a string that can be used to efficiently match the context, /// such as is done in `zizmor`'s template-injection audit via a /// finite state transducer. /// /// Returns None if the context doesn't have a sensible pattern /// equivalent, e.g. if it starts with a call. pub fn as_pattern(&self) -> Option { fn push_part(part: &Expr<'_>, pattern: &mut String) { match part { Expr::Identifier(ident) => pattern.push_str(ident.0), Expr::Star => pattern.push('*'), Expr::Index(idx) => match &idx.inner { // foo['bar'] -> foo.bar Expr::Literal(Literal::String(idx)) => pattern.push_str(idx), // any kind of numeric or computed index, e.g.: // foo[0], foo[1 + 2], foo[bar] _ => pattern.push('*'), }, _ => unreachable!("unexpected part in context pattern"), } } // TODO: Optimization ideas: // 1. Add a happy path for contexts that contain only // identifiers? Problem: case normalization. // 2. Use `regex-automata` to return a case insensitive // automation here? let mut pattern = String::new(); let mut parts = self.parts.iter().peekable(); let head = parts.next()?; if matches!(**head, Expr::Call { .. }) { return None; } push_part(head, &mut pattern); for part in parts { pattern.push('.'); push_part(part, &mut pattern); } pattern.make_ascii_lowercase(); Some(pattern) } } enum Comparison { Child, Match, } /// A `ContextPattern` is a pattern that matches one or more contexts. /// /// It uses a restricted subset of the syntax used by contexts themselves: /// a pattern is always in dotted form and can only contain identifiers /// and wildcards. /// /// Indices are not allowed in patterns themselves, although contexts /// that contain indices can be matched against patterns. For example, /// `github.event.pull_request.assignees.*.name` will match the context /// `github.event.pull_request.assignees[0].name`. pub struct ContextPattern<'src>( // NOTE: Kept as a string as a potentially premature optimization; // re-parsing should be faster in terms of locality. // TODO: Vec instead? &'src str, ); impl<'src> TryFrom<&'src str> for ContextPattern<'src> { type Error = anyhow::Error; fn try_from(val: &'src str) -> anyhow::Result { Self::try_new(val).ok_or_else(|| anyhow::anyhow!("invalid context pattern")) } } impl<'src> ContextPattern<'src> { /// Creates a new [`ContextPattern`] from the given string. /// /// Panics if the pattern is invalid. pub const fn new(pattern: &'src str) -> Self { Self::try_new(pattern).expect("invalid context pattern; use try_new to handle errors") } /// Creates a new [`ContextPattern`] from the given string. /// /// Returns `None` if the pattern is invalid. pub const fn try_new(pattern: &'src str) -> Option { let raw_pattern = pattern.as_bytes(); if raw_pattern.is_empty() { return None; } let len = raw_pattern.len(); // State machine: // - accept_reg: whether the next character can be a regular identifier character // - accept_dot: whether the next character can be a dot // - accept_star: whether the next character can be a star let mut accept_reg = true; let mut accept_dot = false; let mut accept_star = false; let mut idx = 0; while idx < len { accept_dot = accept_dot && idx != len - 1; match raw_pattern[idx] { b'.' => { if !accept_dot { return None; } accept_reg = true; accept_dot = false; accept_star = true; } b'*' => { if !accept_star { return None; } accept_reg = false; accept_star = false; accept_dot = true; } c if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' => { if !accept_reg { return None; } accept_reg = true; accept_dot = true; accept_star = false; } _ => return None, // invalid character } idx += 1; } Some(Self(pattern)) } fn compare_part(pattern: &str, part: &Expr<'src>) -> bool { if pattern == "*" { true } else { match part { Expr::Identifier(part) => pattern.eq_ignore_ascii_case(part.0), Expr::Index(part) => match &part.inner { Expr::Literal(Literal::String(part)) => pattern.eq_ignore_ascii_case(part), _ => false, }, _ => false, } } } fn compare(&self, ctx: &Context<'src>) -> Option { let mut pattern_parts = self.0.split('.').peekable(); let mut ctx_parts = ctx.parts.iter().peekable(); while let (Some(pattern), Some(part)) = (pattern_parts.peek(), ctx_parts.peek()) { if !Self::compare_part(pattern, part) { return None; } pattern_parts.next(); ctx_parts.next(); } match (pattern_parts.next(), ctx_parts.next()) { // If both are exhausted, we have an exact match. (None, None) => Some(Comparison::Match), // If the pattern is exhausted but the context isn't, then // the context is a child of the pattern. (None, Some(_)) => Some(Comparison::Child), _ => None, } } /// Returns true if the given context is a child of the pattern. /// /// This is a loose parent-child relationship; for example, `foo` is its /// own parent, as well as the parent of `foo.bar` and `foo.bar.baz`. pub fn parent_of(&self, ctx: &Context<'src>) -> bool { matches!( self.compare(ctx), Some(Comparison::Child | Comparison::Match) ) } /// Returns true if the given context exactly matches the pattern. /// /// See [`ContextPattern`] for a description of the matching rules. pub fn matches(&self, ctx: &Context<'src>) -> bool { matches!(self.compare(ctx), Some(Comparison::Match)) } } #[cfg(test)] mod tests { use crate::Expr; use super::{Context, ContextPattern}; impl<'a> TryFrom<&'a str> for Context<'a> { type Error = anyhow::Error; fn try_from(val: &'a str) -> anyhow::Result { let expr = Expr::parse(val)?; match expr.inner { Expr::Context(ctx) => Ok(ctx), _ => Err(anyhow::anyhow!("expected context, found {:?}", expr)), } } } #[test] fn test_context_child_of() { let ctx = Context::try_from("foo.bar.baz").unwrap(); for (case, child) in &[ // Trivial child cases. ("foo", true), ("foo.bar", true), // Case-insensitive cases. ("FOO", true), ("FOO.BAR", true), ("Foo", true), ("Foo.Bar", true), // We consider a context to be a child of itself. ("foo.bar.baz", true), // Trivial non-child cases. ("foo.bar.baz.qux", false), ("foo.bar.qux", false), ("foo.qux", false), ("qux", false), // Invalid cases. ("foo.", false), (".", false), ("", false), ] { assert_eq!(ctx.child_of(*case), *child); } } #[test] fn test_single_tail() { for (case, expected) in &[ // Valid cases. ("foo.bar", Some("bar")), ("foo['bar']", Some("bar")), ("inputs.test", Some("test")), // Invalid cases. ("foo.bar.baz", None), // too many parts ("foo.bar.baz.qux", None), // too many parts ("foo['bar']['baz']", None), // too many parts ("foo().bar", None), // head is a call, not an identifier ] { let ctx = Context::try_from(*case).unwrap(); assert_eq!(ctx.single_tail(), *expected); } } #[test] fn test_context_as_pattern() { for (case, expected) in &[ // Basic cases. ("foo", Some("foo")), ("foo.bar", Some("foo.bar")), ("foo.bar.baz", Some("foo.bar.baz")), ("foo.bar.baz_baz", Some("foo.bar.baz_baz")), ("foo.bar.baz-baz", Some("foo.bar.baz-baz")), ("foo.*", Some("foo.*")), ("foo.bar.*", Some("foo.bar.*")), ("foo.*.baz", Some("foo.*.baz")), ("foo.*.*", Some("foo.*.*")), // Case sensitivity. ("FOO", Some("foo")), ("FOO.BAR", Some("foo.bar")), ("FOO.BAR.BAZ", Some("foo.bar.baz")), ("FOO.BAR.BAZ_BAZ", Some("foo.bar.baz_baz")), ("FOO.BAR.BAZ-BAZ", Some("foo.bar.baz-baz")), ("FOO.*", Some("foo.*")), ("FOO.BAR.*", Some("foo.bar.*")), ("FOO.*.BAZ", Some("foo.*.baz")), ("FOO.*.*", Some("foo.*.*")), // Indexes. ("foo.bar.baz[0]", Some("foo.bar.baz.*")), ("foo.bar.baz['abc']", Some("foo.bar.baz.abc")), ("foo.bar.baz[0].qux", Some("foo.bar.baz.*.qux")), ("foo.bar.baz[0].qux[1]", Some("foo.bar.baz.*.qux.*")), ("foo[1][2][3]", Some("foo.*.*.*")), ("foo.bar[abc]", Some("foo.bar.*")), ("foo.bar[abc()]", Some("foo.bar.*")), // Whitespace. ("foo . bar", Some("foo.bar")), ("foo . bar . baz", Some("foo.bar.baz")), ("foo . bar . baz_baz", Some("foo.bar.baz_baz")), ("foo . bar . baz-baz", Some("foo.bar.baz-baz")), ("foo .*", Some("foo.*")), ("foo . bar .*", Some("foo.bar.*")), ("foo .* . baz", Some("foo.*.baz")), ("foo .* .*", Some("foo.*.*")), // Invalid cases ("foo().bar", None), ] { let ctx = Context::try_from(*case).unwrap(); assert_eq!(ctx.as_pattern().as_deref(), *expected); } } #[test] fn test_contextpattern_new() { for (case, expected) in &[ // Well-formed patterns. ("foo", Some("foo")), ("foo.bar", Some("foo.bar")), ("foo.bar.baz", Some("foo.bar.baz")), ("foo.bar.baz_baz", Some("foo.bar.baz_baz")), ("foo.bar.baz-baz", Some("foo.bar.baz-baz")), ("foo.*", Some("foo.*")), ("foo.bar.*", Some("foo.bar.*")), ("foo.*.baz", Some("foo.*.baz")), ("foo.*.*", Some("foo.*.*")), // Invalid patterns. ("", None), ("*", None), ("**", None), (".**", None), (".foo", None), ("foo.", None), (".foo.", None), ("foo.**", None), (".", None), ("foo.bar.", None), ("foo..bar", None), ("foo.bar.baz[0]", None), ("foo.bar.baz['abc']", None), ("foo.bar.baz[0].qux", None), ("foo.bar.baz[0].qux[1]", None), ("❤", None), ("❤.*", None), ] { assert_eq!(ContextPattern::try_new(case).map(|p| p.0), *expected); } } #[test] fn test_contextpattern_parent_of() { for (pattern, ctx, expected) in &[ // Exact contains. ("foo", "foo", true), ("foo.bar", "foo.bar", true), ("foo.bar", "foo['bar']", true), ("foo.bar", "foo['BAR']", true), // Parent relationships ("foo", "foo.bar", true), ("foo.bar", "foo.bar.baz", true), ("foo.*", "foo.bar", true), ("foo.*.baz", "foo.bar.baz", true), ("foo.*.*", "foo.bar.baz.qux", true), ("foo", "foo.bar.baz.qux", true), ("foo.*", "foo.bar.baz.qux", true), ( "secrets", "fromJson(steps.runs.outputs.data).workflow_runs[0].id", false, ), ] { let pattern = ContextPattern::try_new(pattern).unwrap(); let ctx = Context::try_from(*ctx).unwrap(); assert_eq!(pattern.parent_of(&ctx), *expected); } } #[test] fn test_context_pattern_matches() { for (pattern, ctx, expected) in &[ // Normal matches. ("foo", "foo", true), ("foo.bar", "foo.bar", true), ("foo.bar.baz", "foo.bar.baz", true), ("foo.*", "foo.bar", true), ("foo.*.baz", "foo.bar.baz", true), ("foo.*.*", "foo.bar.baz", true), ("foo.*.*.*", "foo.bar.baz.qux", true), // Case-insensitive matches. ("foo.bar", "FOO.BAR", true), ("foo.bar.baz", "Foo.Bar.Baz", true), ("foo.*", "FOO.BAR", true), ("foo.*.baz", "Foo.Bar.Baz", true), ("foo.*.*", "FOO.BAR.BAZ", true), ("FOO.BAR", "foo.bar", true), ("FOO.BAR.BAZ", "foo.bar.baz", true), ("FOO.*", "foo.bar", true), ("FOO.*.BAZ", "foo.bar.baz", true), ("FOO.*.*", "foo.bar.baz", true), // Indices also match correctly. ("foo.bar.baz.*", "foo.bar.baz[0]", true), ("foo.bar.baz.*", "foo.bar.baz[123]", true), ("foo.bar.baz.*", "foo.bar.baz['abc']", true), ("foo.bar.baz.*", "foo['bar']['baz']['abc']", true), ("foo.bar.baz.*", "foo['bar']['BAZ']['abc']", true), // Contexts containing stars match correctly. ("foo.bar.baz.*", "foo.bar.baz.*", true), ("foo.bar.*.*", "foo.bar.*.*", true), ("foo.bar.baz.qux", "foo.bar.baz.*", false), // patterns are one way ("foo.bar.baz.qux", "foo.bar.baz[*]", false), // patterns are one way // False normal matches. ("foo", "bar", false), // different identifier ("foo.bar", "foo.baz", false), // different identifier ("foo.bar", "foo['baz']", false), // different index ("foo.bar.baz", "foo.bar.baz.qux", false), // pattern too short ("foo.bar.baz", "foo.bar", false), // context too short ("foo.*.baz", "foo.bar.baz.qux", false), // pattern too short ("foo.*.qux", "foo.bar.baz.qux", false), // * does not match multiple parts ("foo.*.*", "foo.bar.baz.qux", false), // pattern too short ("foo.1", "foo[1]", false), // .1 means a string key, not an index ] { let pattern = ContextPattern::try_new(pattern) .unwrap_or_else(|| panic!("invalid pattern: {pattern}")); let ctx = Context::try_from(*ctx).unwrap(); assert_eq!(pattern.matches(&ctx), *expected); } } } github-actions-expressions-0.0.11/src/expr.pest000064400000000000000000000047711046102023000176560ustar 00000000000000//! Parser rules for Actions expressions. //! //! See: /// Whitespace handling WHITESPACE = _{ " " | NEWLINE } // Misc notes: // I have pretty low confidence in this grammar -- it should parse the // overwhelming majority of normal looking Actions expressions, but // it makes up for a lack of left-recursion with some hacky rules that // probably aren't sufficient for capturing all possible grammar productions. // I've noted some of these with `HACK` below. // I've also noted some potentials TODOs with `TODO`. expression = { SOI ~ or_expr ~ EOI } /// Logical OR or_expr = { (and_expr ~ ("||" ~ and_expr)*) } /// Logical AND and_expr = { (eq_expr ~ ("&&" ~ eq_expr)*) } /// Structural equality/inequality eq_expr = { (comp_expr ~ (eq_op ~ comp_expr)*) } eq_op = { "==" | "!=" } /// Structural comparison // HACK: parenthetical production of or_expr here to // allow left-parenthetical productions like `(foo || bar) == baz`. // This works well, but I'm not convinced it's right. comp_expr = { unary_expr ~ (comp_op ~ unary_expr)* | ("(" ~ or_expr ~ ")") } comp_op = { ">=" | ">" | "<=" | "<" } /// Unary operations, including the base case for expressions. // HACK: `unary_op ~ or_expr` ensures that we handle non-trivial // negation productions, like `!(true || false)`. unary_expr = { (unary_op? ~ primary_expr) | unary_op ~ or_expr } unary_op = { "!" } primary_expr = { number | string | boolean | null | context | "(" ~ primary_expr ~ ")" } /// Numbers // TODO: Support hex numbers and exponent forms? // Unclear whether these are supported by Actions itself. number = @{ "-"? ~ ("0" | ASCII_NONZERO_DIGIT ~ ASCII_DIGIT*) ~ ("." ~ ASCII_DIGIT+)? } /// Strings string = ${ "'" ~ string_inner ~ "'" } string_inner = @{ string_char* } string_char = @{ !("'") ~ ANY | "'" ~ "'" } /// Booleans boolean = { "true" | "false" } /// Null null = { "null" } /// Context references (e.g., github.event.issue.number) context = { (function_call | identifier) ~ (("." ~ (identifier | star)) | index)* } /// A star within a context component. star = { "*" } /// A single identifier within a context component. identifier = @{ (ASCII_ALPHA | "_" | "-") ~ (ASCII_ALPHANUMERIC | "_" | "-")* } /// An index within a context component. index = !{ ("[" ~ (or_expr | star) ~ "]") } /// Function calls function_call = !{ identifier ~ "(" ~ (or_expr ~ ("," ~ or_expr)*)? ~ ")" } github-actions-expressions-0.0.11/src/identifier.rs000064400000000000000000000014151046102023000204630ustar 00000000000000//! Identifiers. /// Represents a single identifier in a GitHub Actions expression, /// i.e. a single context component. /// /// Identifiers are case-insensitive. #[derive(Debug)] pub struct Identifier<'src>(pub(crate) &'src str); impl Identifier<'_> { /// Returns the identifier as a string slice, as it appears in the /// expression. /// /// Important: identifiers are case-insensitive, so this should not /// be used for comparisons. pub fn as_str(&self) -> &str { self.0 } } impl PartialEq for Identifier<'_> { fn eq(&self, other: &Self) -> bool { self.0.eq_ignore_ascii_case(other.0) } } impl PartialEq for Identifier<'_> { fn eq(&self, other: &str) -> bool { self.0.eq_ignore_ascii_case(other) } } github-actions-expressions-0.0.11/src/lib.rs000064400000000000000000001735251046102023000171230ustar 00000000000000//! GitHub Actions expression parsing and analysis. #![forbid(unsafe_code)] #![deny(missing_docs)] use std::ops::Deref; use crate::{ call::{Call, Function}, context::Context, identifier::Identifier, literal::Literal, op::{BinOp, UnOp}, }; use self::parser::{ExprParser, Rule}; use anyhow::Result; use itertools::Itertools; use pest::{Parser, iterators::Pair}; pub mod call; pub mod context; pub mod identifier; pub mod literal; pub mod op; // Isolates the ExprParser, Rule and other generated types // so that we can do `missing_docs` at the top-level. // See: https://github.com/pest-parser/pest/issues/326 mod parser { use pest_derive::Parser; /// A parser for GitHub Actions' expression language. #[derive(Parser)] #[grammar = "expr.pest"] pub struct ExprParser; } /// Represents the origin of an expression, including its source span /// and unparsed form. #[derive(Copy, Clone, Debug, PartialEq)] pub struct Origin<'src> { /// The expression's source span. pub span: subfeature::Span, /// The expression's unparsed form, as it appears in the source. /// /// This is recorded exactly as it appears in the source, *except* /// that leading and trailing whitespace is stripped. This is stripped /// because it's (1) non-semantic, and (2) can cause all kinds of issues /// when attempting to map expressions back to YAML source features. pub raw: &'src str, } impl<'a> Origin<'a> { /// Create a new origin from the given span and raw form. pub fn new(span: impl Into, raw: &'a str) -> Self { Self { span: span.into(), raw: raw.trim(), } } } /// An expression along with its source origin (span and unparsed form). /// /// Important: Because of how our parser works internally, an expression's /// span is its *rule*'s span, which can be larger than the expression itself. /// For example, `foo || bar || baz` is covered by a single rule, so each /// decomposed `Expr::BinOp` within it will have the same span despite /// logically having different sub-spans of the parent rule's span. #[derive(Debug, PartialEq)] pub struct SpannedExpr<'src> { /// The expression's source origin. pub origin: Origin<'src>, /// The expression itself. pub inner: Expr<'src>, } impl<'a> SpannedExpr<'a> { /// Creates a new `SpannedExpr` from an expression and its span. pub(crate) fn new(origin: Origin<'a>, inner: Expr<'a>) -> Self { Self { origin, inner } } /// Returns the contexts in this expression that directly flow into the /// expression's evaluation. /// /// For example `${{ foo.bar }}` returns `foo.bar` since the value /// of `foo.bar` flows into the evaluation. On the other hand, /// `${{ foo.bar == 'abc' }}` returns no expanded contexts, /// since the value of `foo.bar` flows into a boolean evaluation /// that gets expanded. pub fn dataflow_contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> { let mut contexts = vec![]; match self.deref() { Expr::Call(Call { func, args }) => { // These functions, when evaluated, produce an evaluation // that includes some or all of the contexts listed in // their arguments. if func == "toJSON" || func == "format" || func == "join" { for arg in args { contexts.extend(arg.dataflow_contexts()); } } } // NOTE: We intentionally don't handle the `func(...).foo.bar` // case differently here, since a call followed by a // context access *can* flow into the evaluation. // For example, `${{ fromJSON(something) }}` evaluates to // `Object` but `${{ fromJSON(something).foo }}` evaluates // to the contents of `something.foo`. Expr::Context(ctx) => contexts.push((ctx, &self.origin)), Expr::BinOp { lhs, op, rhs } => match op { // With && only the RHS can flow into the evaluation as a context // (rather than a boolean). BinOp::And => { contexts.extend(rhs.dataflow_contexts()); } // With || either the LHS or RHS can flow into the evaluation as a context. BinOp::Or => { contexts.extend(lhs.dataflow_contexts()); contexts.extend(rhs.dataflow_contexts()); } _ => (), }, _ => (), } contexts } /// Returns any computed indices in this expression. /// /// A computed index is any index operation with a non-literal /// evaluation, e.g. `foo[a.b.c]`. pub fn computed_indices(&self) -> Vec<&SpannedExpr<'a>> { let mut index_exprs = vec![]; match self.deref() { Expr::Call(Call { func: _, args }) => { for arg in args { index_exprs.extend(arg.computed_indices()); } } Expr::Index(spanned_expr) => { // NOTE: We consider any non-literal, non-star index computed. if !spanned_expr.is_literal() && !matches!(spanned_expr.inner, Expr::Star) { index_exprs.push(self); } } Expr::Context(context) => { for part in &context.parts { index_exprs.extend(part.computed_indices()); } } Expr::BinOp { lhs, op: _, rhs } => { index_exprs.extend(lhs.computed_indices()); index_exprs.extend(rhs.computed_indices()); } Expr::UnOp { op: _, expr } => { index_exprs.extend(expr.computed_indices()); } _ => {} } index_exprs } /// Like [`Expr::constant_reducible`], but for all subexpressions /// rather than the top-level expression. /// /// This has slightly different semantics than `constant_reducible`: /// it doesn't include "trivially" reducible expressions like literals, /// since flagging these as reducible within a larger expression /// would be misleading. pub fn constant_reducible_subexprs(&self) -> Vec<&SpannedExpr<'a>> { if !self.is_literal() && self.constant_reducible() { return vec![self]; } let mut subexprs = vec![]; match self.deref() { Expr::Call(Call { func: _, args }) => { for arg in args { subexprs.extend(arg.constant_reducible_subexprs()); } } Expr::Context(ctx) => { // contexts themselves are never reducible, but they might // contains reducible index subexpressions. for part in &ctx.parts { subexprs.extend(part.constant_reducible_subexprs()); } } Expr::BinOp { lhs, op: _, rhs } => { subexprs.extend(lhs.constant_reducible_subexprs()); subexprs.extend(rhs.constant_reducible_subexprs()); } Expr::UnOp { op: _, expr } => subexprs.extend(expr.constant_reducible_subexprs()), Expr::Index(expr) => subexprs.extend(expr.constant_reducible_subexprs()), _ => {} } subexprs } } impl<'a> Deref for SpannedExpr<'a> { type Target = Expr<'a>; fn deref(&self) -> &Self::Target { &self.inner } } impl<'doc> From<&SpannedExpr<'doc>> for subfeature::Fragment<'doc> { fn from(expr: &SpannedExpr<'doc>) -> Self { Self::new(expr.origin.raw) } } /// Represents a GitHub Actions expression. #[derive(Debug, PartialEq)] pub enum Expr<'src> { /// A literal value. Literal(Literal<'src>), /// The `*` literal within an index or context. Star, /// A function call. Call(Call<'src>), /// A context identifier component, e.g. `github` in `github.actor`. Identifier(Identifier<'src>), /// A context index component, e.g. `[0]` in `foo[0]`. Index(Box>), /// A full context reference. Context(Context<'src>), /// A binary operation, either logical or arithmetic. BinOp { /// The LHS of the binop. lhs: Box>, /// The binary operator. op: BinOp, /// The RHS of the binop. rhs: Box>, }, /// A unary operation. Negation (`!`) is currently the only `UnOp`. UnOp { /// The unary operator. op: UnOp, /// The expression to apply the operator to. expr: Box>, }, } impl<'src> Expr<'src> { /// Convenience API for making an [`Expr::Identifier`]. fn ident(i: &'src str) -> Self { Self::Identifier(Identifier(i)) } /// Convenience API for making an [`Expr::Context`]. fn context(components: impl Into>>) -> Self { Self::Context(Context::new(components)) } /// Returns whether the expression is a literal. pub fn is_literal(&self) -> bool { matches!(self, Expr::Literal(_)) } /// Returns whether the expression is constant reducible. /// /// "Constant reducible" is similar to "constant foldable" but with /// meta-evaluation semantics: the expression `5` would not be /// constant foldable in a normal program (because it's already /// an atom), but is "constant reducible" in a GitHub Actions expression /// because an expression containing it (e.g. `${{ 5 }}`) can be elided /// entirely and replaced with `5`. /// /// There are three kinds of reducible expressions: /// /// 1. Literals, which reduce to their literal value; /// 2. Binops/unops with reducible subexpressions, which reduce /// to their evaluation; /// 3. Select function calls where the semantics of the function /// mean that reducible arguments make the call itself reducible. /// /// NOTE: This implementation is sound but not complete. pub fn constant_reducible(&self) -> bool { match self { // Literals are always reducible. Expr::Literal(_) => true, // Binops are reducible if their LHS and RHS are reducible. Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(), // Unops are reducible if their interior expression is reducible. Expr::UnOp { op: _, expr } => expr.constant_reducible(), Expr::Call(Call { func, args }) => { // These functions are reducible if their arguments are reducible. if func == "format" || func == "contains" || func == "startsWith" || func == "endsWith" || func == "toJSON" // TODO(ww): `fromJSON` *is* frequently reducible, but // doing so soundly with subexpressions is annoying. // We overapproximate for now and consider it non-reducible. // || func == "fromJSON" || func == "join" { args.iter().all(|e| e.constant_reducible()) } else { false } } // Everything else is presumed non-reducible. _ => false, } } /// Parses the given string into an expression. #[allow(clippy::unwrap_used)] pub fn parse(expr: &'src str) -> Result> { // Top level `expression` is a single `or_expr`. let or_expr = ExprParser::parse(Rule::expression, expr)? .next() .unwrap() .into_inner() .next() .unwrap(); fn parse_pair(pair: Pair<'_, Rule>) -> Result>> { // We're parsing a pest grammar, which isn't left-recursive. // As a result, we have constructions like // `or_expr = { and_expr ~ ("||" ~ and_expr)* }`, which // result in wonky ASTs like one or many (>2) headed ORs. // We turn these into sane looking ASTs by punching the single // pairs down to their primitive type and folding the // many-headed pairs appropriately. // For example, `or_expr` matches the `1` one but punches through // to `Number(1)`, and also matches `true || true || true` which // becomes `BinOp(BinOp(true, true), true)`. match pair.as_rule() { Rule::or_expr => { let (span, raw) = (pair.as_span(), pair.as_str()); let mut pairs = pair.into_inner(); let lhs = parse_pair(pairs.next().unwrap())?; pairs.try_fold(lhs, |expr, next| { Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::BinOp { lhs: expr, op: BinOp::Or, rhs: parse_pair(next)?, }, ) .into()) }) } Rule::and_expr => { let (span, raw) = (pair.as_span(), pair.as_str()); let mut pairs = pair.into_inner(); let lhs = parse_pair(pairs.next().unwrap())?; pairs.try_fold(lhs, |expr, next| { Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::BinOp { lhs: expr, op: BinOp::And, rhs: parse_pair(next)?, }, ) .into()) }) } Rule::eq_expr => { // eq_expr matches both `==` and `!=` and captures // them in the `eq_op` capture, so we fold with // two-tuples of (eq_op, comp_expr). let (span, raw) = (pair.as_span(), pair.as_str()); let mut pairs = pair.into_inner(); let lhs = parse_pair(pairs.next().unwrap())?; let pair_chunks = pairs.chunks(2); pair_chunks.into_iter().try_fold(lhs, |expr, mut next| { let eq_op = next.next().unwrap(); let comp_expr = next.next().unwrap(); let eq_op = match eq_op.as_str() { "==" => BinOp::Eq, "!=" => BinOp::Neq, _ => unreachable!(), }; Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::BinOp { lhs: expr, op: eq_op, rhs: parse_pair(comp_expr)?, }, ) .into()) }) } Rule::comp_expr => { // Same as eq_expr, but with comparison operators. let (span, raw) = (pair.as_span(), pair.as_str()); let mut pairs = pair.into_inner(); let lhs = parse_pair(pairs.next().unwrap())?; let pair_chunks = pairs.chunks(2); pair_chunks.into_iter().try_fold(lhs, |expr, mut next| { let comp_op = next.next().unwrap(); let unary_expr = next.next().unwrap(); let eq_op = match comp_op.as_str() { ">" => BinOp::Gt, ">=" => BinOp::Ge, "<" => BinOp::Lt, "<=" => BinOp::Le, _ => unreachable!(), }; Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::BinOp { lhs: expr, op: eq_op, rhs: parse_pair(unary_expr)?, }, ) .into()) }) } Rule::unary_expr => { let (span, raw) = (pair.as_span(), pair.as_str()); let mut pairs = pair.into_inner(); let inner_pair = pairs.next().unwrap(); match inner_pair.as_rule() { Rule::unary_op => Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::UnOp { op: UnOp::Not, expr: parse_pair(pairs.next().unwrap())?, }, ) .into()), Rule::primary_expr => parse_pair(inner_pair), _ => unreachable!(), } } Rule::primary_expr => { // Punt back to the top level match to keep things simple. parse_pair(pair.into_inner().next().unwrap()) } Rule::number => Ok(SpannedExpr::new( Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()), pair.as_str().parse::().unwrap().into(), ) .into()), Rule::string => { let (span, raw) = (pair.as_span(), pair.as_str()); // string -> string_inner let string_inner = pair.into_inner().next().unwrap().as_str(); // Optimization: if our string literal doesn't have any // escaped quotes in it, we can save ourselves a clone. if !string_inner.contains('\'') { Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), string_inner.into(), ) .into()) } else { Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), string_inner.replace("''", "'").into(), ) .into()) } } Rule::boolean => Ok(SpannedExpr::new( Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()), pair.as_str().parse::().unwrap().into(), ) .into()), Rule::null => Ok(SpannedExpr::new( Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()), Expr::Literal(Literal::Null), ) .into()), Rule::star => Ok(SpannedExpr::new( Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()), Expr::Star, ) .into()), Rule::function_call => { let (span, raw) = (pair.as_span(), pair.as_str()); let mut pairs = pair.into_inner(); let identifier = pairs.next().unwrap(); let args = pairs .map(|pair| parse_pair(pair).map(|e| *e)) .collect::>()?; Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::Call(Call { func: Function(identifier.as_str()), args, }), ) .into()) } Rule::identifier => Ok(SpannedExpr::new( Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()), Expr::ident(pair.as_str()), ) .into()), Rule::index => Ok(SpannedExpr::new( Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()), Expr::Index(parse_pair(pair.into_inner().next().unwrap())?), ) .into()), Rule::context => { let (span, raw) = (pair.as_span(), pair.as_str()); let pairs = pair.into_inner(); let mut inner: Vec = pairs .map(|pair| parse_pair(pair).map(|e| *e)) .collect::>()?; // NOTE(ww): Annoying specialization: the `context` rule // wholly encloses the `function_call` rule, so we clean up // the AST slightly to turn `Context { Call }` into just `Call`. if inner.len() == 1 && matches!(inner[0].inner, Expr::Call { .. }) { Ok(inner.remove(0).into()) } else { Ok(SpannedExpr::new( Origin::new(span.start()..span.end(), raw), Expr::context(inner), ) .into()) } } r => panic!("unrecognized rule: {r:?}"), } } parse_pair(or_expr).map(|e| *e) } } impl<'src> From<&'src str> for Expr<'src> { fn from(s: &'src str) -> Self { Expr::Literal(Literal::String(s.into())) } } impl From for Expr<'_> { fn from(s: String) -> Self { Expr::Literal(Literal::String(s.into())) } } impl From for Expr<'_> { fn from(n: f64) -> Self { Expr::Literal(Literal::Number(n)) } } impl From for Expr<'_> { fn from(b: bool) -> Self { Expr::Literal(Literal::Boolean(b)) } } /// The result of evaluating a GitHub Actions expression. /// /// This type represents the possible values that can result from evaluating /// GitHub Actions expressions. #[derive(Debug, Clone, PartialEq)] pub enum Evaluation { /// A string value (includes both string literals and stringified other types). String(String), /// A numeric value. Number(f64), /// A boolean value. Boolean(bool), /// The null value. Null, /// An array value. Array evaluations can only be realized through `fromJSON`. Array(Vec), /// An object value. Object evaluations can only be realized through `fromJSON`. Object(std::collections::HashMap), } impl TryFrom for Evaluation { type Error = (); fn try_from(value: serde_json::Value) -> Result { match value { serde_json::Value::Null => Ok(Evaluation::Null), serde_json::Value::Bool(b) => Ok(Evaluation::Boolean(b)), serde_json::Value::Number(n) => { if let Some(f) = n.as_f64() { Ok(Evaluation::Number(f)) } else { Err(()) } } serde_json::Value::String(s) => Ok(Evaluation::String(s)), serde_json::Value::Array(arr) => { let elements = arr .into_iter() .map(|elem| elem.try_into()) .collect::>()?; Ok(Evaluation::Array(elements)) } serde_json::Value::Object(obj) => { let mut map = std::collections::HashMap::new(); for (key, value) in obj { map.insert(key, value.try_into()?); } Ok(Evaluation::Object(map)) } } } } impl TryInto for Evaluation { type Error = (); fn try_into(self) -> Result { match self { Evaluation::Null => Ok(serde_json::Value::Null), Evaluation::Boolean(b) => Ok(serde_json::Value::Bool(b)), Evaluation::Number(n) => { // NOTE: serde_json has different internal representations // for integers and floats, so we need to handle both cases // to ensure we serialize integers without a decimal point. if n.fract() == 0.0 { Ok(serde_json::Value::Number(serde_json::Number::from( n as i64, ))) } else if let Some(num) = serde_json::Number::from_f64(n) { Ok(serde_json::Value::Number(num)) } else { Err(()) } } Evaluation::String(s) => Ok(serde_json::Value::String(s)), Evaluation::Array(arr) => { let elements = arr .into_iter() .map(|elem| elem.try_into()) .collect::>()?; Ok(serde_json::Value::Array(elements)) } Evaluation::Object(obj) => { let mut map = serde_json::Map::new(); for (key, value) in obj { map.insert(key, value.try_into()?); } Ok(serde_json::Value::Object(map)) } } } } impl Evaluation { /// Convert to a boolean following GitHub Actions truthiness rules. /// /// GitHub Actions truthiness: /// - false and null are falsy /// - Numbers: 0 is falsy, everything else is truthy /// - Strings: empty string is falsy, everything else is truthy /// - Arrays and dictionaries are always truthy (non-empty objects) pub fn as_boolean(&self) -> bool { match self { Evaluation::Boolean(b) => *b, Evaluation::Null => false, Evaluation::Number(n) => *n != 0.0, Evaluation::String(s) => !s.is_empty(), // Arrays and objects are always truthy, even if empty. Evaluation::Array(_) | Evaluation::Object(_) => true, } } /// Convert to a number following GitHub Actions conversion rules. /// /// See: pub fn as_number(&self) -> f64 { match self { Evaluation::String(s) => { if s.is_empty() { 0.0 } else { s.parse::().unwrap_or(f64::NAN) } } Evaluation::Number(n) => *n, Evaluation::Boolean(b) => { if *b { 1.0 } else { 0.0 } } Evaluation::Null => 0.0, Evaluation::Array(_) | Evaluation::Object(_) => f64::NAN, } } /// Returns a wrapper around this evaluation that implements /// GitHub Actions evaluation semantics. pub fn sema(&self) -> EvaluationSema<'_> { EvaluationSema(self) } } /// A wrapper around `Evaluation` that implements GitHub Actions /// various evaluation semantics (comparison, stringification, etc.). pub struct EvaluationSema<'a>(&'a Evaluation); impl PartialEq for EvaluationSema<'_> { fn eq(&self, other: &Self) -> bool { match (self.0, other.0) { (Evaluation::Null, Evaluation::Null) => true, (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a == b, (Evaluation::Number(a), Evaluation::Number(b)) => a == b, (Evaluation::String(a), Evaluation::String(b)) => a == b, // Coercion rules: all others convert to number and compare. (a, b) => a.as_number() == b.as_number(), } } } impl PartialOrd for EvaluationSema<'_> { fn partial_cmp(&self, other: &Self) -> Option { match (self.0, other.0) { (Evaluation::Null, Evaluation::Null) => Some(std::cmp::Ordering::Equal), (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a.partial_cmp(b), (Evaluation::Number(a), Evaluation::Number(b)) => a.partial_cmp(b), (Evaluation::String(a), Evaluation::String(b)) => a.partial_cmp(b), // Coercion rules: all others convert to number and compare. (a, b) => a.as_number().partial_cmp(&b.as_number()), } } } impl std::fmt::Display for EvaluationSema<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.0 { Evaluation::String(s) => write!(f, "{}", s), Evaluation::Number(n) => { // Format numbers like GitHub Actions does if n.fract() == 0.0 { write!(f, "{}", *n as i64) } else { write!(f, "{}", n) } } Evaluation::Boolean(b) => write!(f, "{}", b), Evaluation::Null => write!(f, ""), Evaluation::Array(_) => write!(f, "Array"), Evaluation::Object(_) => write!(f, "Object"), } } } impl<'src> Expr<'src> { /// Evaluates a constant-reducible expression to its literal value. /// /// Returns `Some(Evaluation)` if the expression can be constant-evaluated, /// or `None` if the expression contains non-constant elements (like contexts or /// non-reducible function calls). /// /// This implementation follows GitHub Actions' evaluation semantics as documented at: /// https://docs.github.com/en/actions/reference/workflows-and-actions/expressions /// /// # Examples /// /// ``` /// use github_actions_expressions::{Expr, Evaluation}; /// /// let expr = Expr::parse("'hello'").unwrap(); /// let result = expr.consteval().unwrap(); /// assert_eq!(result.sema().to_string(), "hello"); /// /// let expr = Expr::parse("true && false").unwrap(); /// let result = expr.consteval().unwrap(); /// assert_eq!(result, Evaluation::Boolean(false)); /// ``` pub fn consteval(&self) -> Option { match self { Expr::Literal(literal) => Some(literal.consteval()), Expr::BinOp { lhs, op, rhs } => { let lhs_val = lhs.consteval()?; let rhs_val = rhs.consteval()?; match op { BinOp::And => { // GitHub Actions && semantics: if LHS is falsy, return LHS, else return RHS if lhs_val.as_boolean() { Some(rhs_val) } else { Some(lhs_val) } } BinOp::Or => { // GitHub Actions || semantics: if LHS is truthy, return LHS, else return RHS if lhs_val.as_boolean() { Some(lhs_val) } else { Some(rhs_val) } } BinOp::Eq => Some(Evaluation::Boolean(lhs_val.sema() == rhs_val.sema())), BinOp::Neq => Some(Evaluation::Boolean(lhs_val.sema() != rhs_val.sema())), BinOp::Lt => Some(Evaluation::Boolean(lhs_val.sema() < rhs_val.sema())), BinOp::Le => Some(Evaluation::Boolean(lhs_val.sema() <= rhs_val.sema())), BinOp::Gt => Some(Evaluation::Boolean(lhs_val.sema() > rhs_val.sema())), BinOp::Ge => Some(Evaluation::Boolean(lhs_val.sema() >= rhs_val.sema())), } } Expr::UnOp { op, expr } => { let val = expr.consteval()?; match op { UnOp::Not => Some(Evaluation::Boolean(!val.as_boolean())), } } Expr::Call(call) => call.consteval(), // Non-constant expressions _ => None, } } } #[cfg(test)] mod tests { use std::borrow::Cow; use anyhow::Result; use pest::Parser as _; use pretty_assertions::assert_eq; use crate::{Call, Literal, Origin, SpannedExpr}; use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp}; #[test] fn test_literal_string_borrows() { let cases = &[ ("'foo'", true), ("'foo bar'", true), ("'foo '' bar'", false), ("'foo''bar'", false), ("'foo''''bar'", false), ]; for (expr, borrows) in cases { let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else { panic!("expected a literal string expression for {expr}"); }; assert!(matches!( (s, borrows), (Cow::Borrowed(_), true) | (Cow::Owned(_), false) )); } } #[test] fn test_literal_as_str() { let cases = &[ ("'foo'", "foo"), ("'foo '' bar'", "foo ' bar"), ("123", "123"), ("123.000", "123"), ("0.0", "0"), ("0.1", "0.1"), ("0.12345", "0.12345"), ("true", "true"), ("false", "false"), ("null", "null"), ]; for (expr, expected) in cases { let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else { panic!("expected a literal expression for {expr}"); }; assert_eq!(expr.as_str(), *expected); } } #[test] fn test_function_eq() { let func = Function("foo"); assert_eq!(&func, "foo"); assert_eq!(&func, "FOO"); assert_eq!(&func, "Foo"); assert_eq!(func, Function("FOO")); } #[test] fn test_parse_string_rule() { let cases = &[ ("''", ""), ("' '", " "), ("''''", "''"), ("'test'", "test"), ("'spaces are ok'", "spaces are ok"), ("'escaping '' works'", "escaping '' works"), ]; for (case, expected) in cases { let s = ExprParser::parse(Rule::string, case) .unwrap() .next() .unwrap(); assert_eq!(s.into_inner().next().unwrap().as_str(), *expected); } } #[test] fn test_parse_context_rule() { let cases = &[ "foo.bar", "github.action_path", "inputs.foo-bar", "inputs.also--valid", "inputs.this__too", "inputs.this__too", "secrets.GH_TOKEN", "foo.*.bar", "github.event.issue.labels.*.name", ]; for case in cases { assert_eq!( ExprParser::parse(Rule::context, case) .unwrap() .next() .unwrap() .as_str(), *case ); } } #[test] fn test_parse_call_rule() { let cases = &[ "foo()", "foo(bar)", "foo(bar())", "foo(1.23)", "foo(1,2)", "foo(1, 2)", "foo(1, 2, secret.GH_TOKEN)", "foo( )", "fromJSON(inputs.free-threading)", ]; for case in cases { assert_eq!( ExprParser::parse(Rule::function_call, case) .unwrap() .next() .unwrap() .as_str(), *case ); } } #[test] fn test_parse_expr_rule() -> Result<()> { // Ensures that we parse multi-line expressions correctly. let multiline = "github.repository_owner == 'Homebrew' && ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') || (github.event_name == 'pull_request_target' && (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))"; let multiline2 = "foo.bar.baz[ 0 ]"; let cases = &[ "true", "fromJSON(inputs.free-threading) && '--disable-gil' || ''", "foo || bar || baz", "foo || bar && baz || foo && 1 && 2 && 3 || 4", "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'", "(true || false) == true", "!(!true || false)", "!(!true || false) == true", "(true == false) == true", "(true == (false || true && (true || false))) == true", "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'", "foo()[0]", "fromJson(steps.runs.outputs.data).workflow_runs[0].id", multiline, "'a' == 'b' && 'c' || 'd'", "github.event['a']", "github.event['a' == 'b']", "github.event['a' == 'b' && 'c' || 'd']", "github['event']['inputs']['dry-run']", "github[format('{0}', 'event')]", "github['event']['inputs'][github.event.inputs.magic]", "github['event']['inputs'].*", "1 == 1", "1 > 1", "1 >= 1", "matrix.node_version >= 20", "true||false", multiline2, "fromJSON( github.event.inputs.hmm ) [ 0 ]", ]; for case in cases { assert_eq!( ExprParser::parse(Rule::expression, case)? .next() .unwrap() .as_str(), *case ); } Ok(()) } #[test] fn test_parse() { let cases = &[ ( "!true || false || true", SpannedExpr::new( Origin::new(0..22, "!true || false || true"), Expr::BinOp { lhs: SpannedExpr::new( Origin::new(0..22, "!true || false || true"), Expr::BinOp { lhs: SpannedExpr::new( Origin::new(0..5, "!true"), Expr::UnOp { op: UnOp::Not, expr: SpannedExpr::new( Origin::new(1..5, "true"), true.into(), ) .into(), }, ) .into(), op: BinOp::Or, rhs: SpannedExpr::new(Origin::new(9..14, "false"), false.into()) .into(), }, ) .into(), op: BinOp::Or, rhs: SpannedExpr::new(Origin::new(18..22, "true"), true.into()).into(), }, ), ), ( "'foo '' bar'", SpannedExpr::new( Origin::new(0..12, "'foo '' bar'"), Expr::Literal(Literal::String("foo ' bar".into())), ), ), ( "('foo '' bar')", SpannedExpr::new( Origin::new(1..13, "'foo '' bar'"), Expr::Literal(Literal::String("foo ' bar".into())), ), ), ( "((('foo '' bar')))", SpannedExpr::new( Origin::new(3..15, "'foo '' bar'"), Expr::Literal(Literal::String("foo ' bar".into())), ), ), ( "foo(1, 2, 3)", SpannedExpr::new( Origin::new(0..12, "foo(1, 2, 3)"), Expr::Call(Call { func: Function("foo"), args: vec![ SpannedExpr::new(Origin::new(4..5, "1"), 1.0.into()), SpannedExpr::new(Origin::new(7..8, "2"), 2.0.into()), SpannedExpr::new(Origin::new(10..11, "3"), 3.0.into()), ], }), ), ), ( "foo.bar.baz", SpannedExpr::new( Origin::new(0..11, "foo.bar.baz"), Expr::context(vec![ SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")), SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")), SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")), ]), ), ), ( "foo.bar.baz[1][2]", SpannedExpr::new( Origin::new(0..17, "foo.bar.baz[1][2]"), Expr::context(vec![ SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")), SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")), SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")), SpannedExpr::new( Origin::new(11..14, "[1]"), Expr::Index(Box::new(SpannedExpr::new( Origin::new(12..13, "1"), 1.0.into(), ))), ), SpannedExpr::new( Origin::new(14..17, "[2]"), Expr::Index(Box::new(SpannedExpr::new( Origin::new(15..16, "2"), 2.0.into(), ))), ), ]), ), ), ( "foo.bar.baz[*]", SpannedExpr::new( Origin::new(0..14, "foo.bar.baz[*]"), Expr::context([ SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")), SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")), SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")), SpannedExpr::new( Origin::new(11..14, "[*]"), Expr::Index(Box::new(SpannedExpr::new( Origin::new(12..13, "*"), Expr::Star, ))), ), ]), ), ), ( "vegetables.*.ediblePortions", SpannedExpr::new( Origin::new(0..27, "vegetables.*.ediblePortions"), Expr::context(vec![ SpannedExpr::new( Origin::new(0..10, "vegetables"), Expr::ident("vegetables"), ), SpannedExpr::new(Origin::new(11..12, "*"), Expr::Star), SpannedExpr::new( Origin::new(13..27, "ediblePortions"), Expr::ident("ediblePortions"), ), ]), ), ), ( // Sanity check for our associativity: the top level Expr here // should be `BinOp::Or`. "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'", SpannedExpr::new( Origin::new( 0..88, "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'", ), Expr::BinOp { lhs: Box::new(SpannedExpr::new( Origin::new( 0..59, "github.ref == 'refs/heads/main' && 'value_for_main_branch'", ), Expr::BinOp { lhs: Box::new(SpannedExpr::new( Origin::new(0..32, "github.ref == 'refs/heads/main'"), Expr::BinOp { lhs: Box::new(SpannedExpr::new( Origin::new(0..10, "github.ref"), Expr::context(vec![ SpannedExpr::new( Origin::new(0..6, "github"), Expr::ident("github"), ), SpannedExpr::new( Origin::new(7..10, "ref"), Expr::ident("ref"), ), ]), )), op: BinOp::Eq, rhs: Box::new(SpannedExpr::new( Origin::new(14..31, "'refs/heads/main'"), Expr::Literal(Literal::String( "refs/heads/main".into(), )), )), }, )), op: BinOp::And, rhs: Box::new(SpannedExpr::new( Origin::new(35..58, "'value_for_main_branch'"), Expr::Literal(Literal::String("value_for_main_branch".into())), )), }, )), op: BinOp::Or, rhs: Box::new(SpannedExpr::new( Origin::new(62..88, "'value_for_other_branches'"), Expr::Literal(Literal::String("value_for_other_branches".into())), )), }, ), ), ( "(true || false) == true", SpannedExpr::new( Origin::new(0..23, "(true || false) == true"), Expr::BinOp { lhs: Box::new(SpannedExpr::new( Origin::new(1..14, "true || false"), Expr::BinOp { lhs: Box::new(SpannedExpr::new( Origin::new(1..5, "true"), true.into(), )), op: BinOp::Or, rhs: Box::new(SpannedExpr::new( Origin::new(9..14, "false"), false.into(), )), }, )), op: BinOp::Eq, rhs: Box::new(SpannedExpr::new(Origin::new(19..23, "true"), true.into())), }, ), ), ( "!(!true || false)", SpannedExpr::new( Origin::new(0..17, "!(!true || false)"), Expr::UnOp { op: UnOp::Not, expr: Box::new(SpannedExpr::new( Origin::new(2..16, "!true || false"), Expr::BinOp { lhs: Box::new(SpannedExpr::new( Origin::new(2..7, "!true"), Expr::UnOp { op: UnOp::Not, expr: Box::new(SpannedExpr::new( Origin::new(3..7, "true"), true.into(), )), }, )), op: BinOp::Or, rhs: Box::new(SpannedExpr::new( Origin::new(11..16, "false"), false.into(), )), }, )), }, ), ), ( "foobar[format('{0}', 'event')]", SpannedExpr::new( Origin::new(0..30, "foobar[format('{0}', 'event')]"), Expr::context([ SpannedExpr::new(Origin::new(0..6, "foobar"), Expr::ident("foobar")), SpannedExpr::new( Origin::new(6..30, "[format('{0}', 'event')]"), Expr::Index(Box::new(SpannedExpr::new( Origin::new(7..29, "format('{0}', 'event')"), Expr::Call(Call { func: Function("format"), args: vec![ SpannedExpr::new( Origin::new(14..19, "'{0}'"), Expr::from("{0}"), ), SpannedExpr::new( Origin::new(21..28, "'event'"), Expr::from("event"), ), ], }), ))), ), ]), ), ), ( "github.actor_id == '49699333'", SpannedExpr::new( Origin::new(0..29, "github.actor_id == '49699333'"), Expr::BinOp { lhs: SpannedExpr::new( Origin::new(0..15, "github.actor_id"), Expr::context(vec![ SpannedExpr::new( Origin::new(0..6, "github"), Expr::ident("github"), ), SpannedExpr::new( Origin::new(7..15, "actor_id"), Expr::ident("actor_id"), ), ]), ) .into(), op: BinOp::Eq, rhs: Box::new(SpannedExpr::new( Origin::new(19..29, "'49699333'"), Expr::from("49699333"), )), }, ), ), ]; for (case, expr) in cases { assert_eq!(*expr, Expr::parse(case).unwrap()); } } #[test] fn test_expr_constant_reducible() -> Result<()> { for (expr, reducible) in &[ ("'foo'", true), ("1", true), ("true", true), ("null", true), // boolean and unary expressions of all literals are // always reducible. ("!true", true), ("!null", true), ("true && false", true), ("true || false", true), ("null && !null && true", true), // formats/contains/startsWith/endsWith are reducible // if all of their arguments are reducible. ("format('{0} {1}', 'foo', 'bar')", true), ("format('{0} {1}', 1, 2)", true), ("format('{0} {1}', 1, '2')", true), ("contains('foo', 'bar')", true), ("startsWith('foo', 'bar')", true), ("endsWith('foo', 'bar')", true), ("startsWith(some.context, 'bar')", false), ("endsWith(some.context, 'bar')", false), // Nesting works as long as the nested call is also reducible. ("format('{0} {1}', '1', format('{0}', null))", true), ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true), ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false), ("foo", false), ("foo.bar", false), ("foo.bar[1]", false), ("foo.bar == 'bar'", false), ("foo.bar || bar || baz", false), ("foo.bar && bar && baz", false), ] { let expr = Expr::parse(expr)?; assert_eq!(expr.constant_reducible(), *reducible); } Ok(()) } #[test] fn test_evaluate_constant_complex_expressions() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Nested operations ("!false", Evaluation::Boolean(true)), ("!true", Evaluation::Boolean(false)), ("!(true && false)", Evaluation::Boolean(true)), // Complex boolean logic ("true && (false || true)", Evaluation::Boolean(true)), ("false || (true && false)", Evaluation::Boolean(false)), // Mixed function calls ( "contains(format('{0} {1}', 'hello', 'world'), 'world')", Evaluation::Boolean(true), ), ( "startsWith(format('prefix_{0}', 'test'), 'prefix')", Evaluation::Boolean(true), ), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_evaluation_sema_display() { use crate::Evaluation; let test_cases = &[ (Evaluation::String("hello".to_string()), "hello"), (Evaluation::Number(42.0), "42"), (Evaluation::Number(3.14), "3.14"), (Evaluation::Boolean(true), "true"), (Evaluation::Boolean(false), "false"), (Evaluation::Null, ""), ]; for (result, expected) in test_cases { assert_eq!(result.sema().to_string(), *expected); } } #[test] fn test_evaluation_result_to_boolean() { use crate::Evaluation; let test_cases = &[ (Evaluation::Boolean(true), true), (Evaluation::Boolean(false), false), (Evaluation::Null, false), (Evaluation::Number(0.0), false), (Evaluation::Number(1.0), true), (Evaluation::Number(-1.0), true), (Evaluation::String("".to_string()), false), (Evaluation::String("hello".to_string()), true), (Evaluation::Array(vec![]), true), // Arrays are always truthy (Evaluation::Object(std::collections::HashMap::new()), true), // Dictionaries are always truthy ]; for (result, expected) in test_cases { assert_eq!(result.as_boolean(), *expected); } } #[test] fn test_github_actions_logical_semantics() -> Result<()> { use crate::Evaluation; // Test GitHub Actions-specific && and || semantics let test_cases = &[ // && returns the first falsy value, or the last value if all are truthy ("false && 'hello'", Evaluation::Boolean(false)), ("null && 'hello'", Evaluation::Null), ("'' && 'hello'", Evaluation::String("".to_string())), ( "'hello' && 'world'", Evaluation::String("world".to_string()), ), ("true && 42", Evaluation::Number(42.0)), // || returns the first truthy value, or the last value if all are falsy ("true || 'hello'", Evaluation::Boolean(true)), ( "'hello' || 'world'", Evaluation::String("hello".to_string()), ), ("false || 'hello'", Evaluation::String("hello".to_string())), ("null || false", Evaluation::Boolean(false)), ("'' || null", Evaluation::Null), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } #[test] fn test_expr_has_constant_reducible_subexpr() -> Result<()> { for (expr, reducible) in &[ // Literals are not considered reducible subexpressions. ("'foo'", false), ("1", false), ("true", false), ("null", false), // Non-reducible expressions with reducible subexpressions ( "format('{0}, {1}', github.event.number, format('{0}', 'abc'))", true, ), ("foobar[format('{0}', 'event')]", true), ] { let expr = Expr::parse(expr)?; assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible); } Ok(()) } #[test] fn test_expr_dataflow_contexts() -> Result<()> { // Trivial cases. let expr = Expr::parse("foo.bar")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["foo.bar"] ); let expr = Expr::parse("foo.bar[1]")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["foo.bar[1]"] ); // No dataflow due to a boolean expression. let expr = Expr::parse("foo.bar == 'bar'")?; assert!(expr.dataflow_contexts().is_empty()); // ||: all contexts potentially expand into the evaluation. let expr = Expr::parse("foo.bar || abc || d.e.f")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["foo.bar", "abc", "d.e.f"] ); // &&: only the RHS context(s) expand into the evaluation. let expr = Expr::parse("foo.bar && abc && d.e.f")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["d.e.f"] ); let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["foo.bar"] ); let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["foo.bar", "foo.baz"] ); let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"] ); let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?; assert_eq!( expr.dataflow_contexts() .iter() .map(|t| t.1.raw) .collect::>(), ["foo.bar", "github", "github"] ); Ok(()) } #[test] fn test_spannedexpr_computed_indices() -> Result<()> { for (expr, computed_indices) in &[ ("foo.bar", vec![]), ("foo.bar[1]", vec![]), ("foo.bar[*]", vec![]), ("foo.bar[abc]", vec!["[abc]"]), ( "foo.bar[format('{0}', 'foo')]", vec!["[format('{0}', 'foo')]"], ), ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]), ] { let expr = Expr::parse(expr)?; assert_eq!( expr.computed_indices() .iter() .map(|e| e.origin.raw) .collect::>(), *computed_indices ); } Ok(()) } #[test] fn test_fragment_from_expr() { for (expr, expected) in &[ ("foo==bar", "foo==bar"), ("foo == bar", "foo == bar"), ("foo == bar", r"foo == bar"), ("foo(bar)", "foo(bar)"), ("foo(bar, baz)", "foo(bar, baz)"), ("foo (bar, baz)", "foo (bar, baz)"), ("a . b . c . d", "a . b . c . d"), ("true \n && \n false", r"true\s+\&\&\s+false"), ] { let expr = Expr::parse(expr).unwrap(); match subfeature::Fragment::from(&expr) { subfeature::Fragment::Raw(actual) => assert_eq!(actual, *expected), subfeature::Fragment::Regex(actual) => assert_eq!(actual.as_str(), *expected), }; } } } github-actions-expressions-0.0.11/src/literal.rs000064400000000000000000000043571046102023000200050ustar 00000000000000//! Literal values. use std::borrow::Cow; use crate::Evaluation; /// Represents a literal value in a GitHub Actions expression. #[derive(Debug, PartialEq)] pub enum Literal<'src> { /// A number literal. Number(f64), /// A string literal. String(Cow<'src, str>), /// A boolean literal. Boolean(bool), /// The `null` literal. Null, } impl<'src> Literal<'src> { /// Returns a string representation of the literal. /// /// This is not guaranteed to be an exact equivalent of the literal /// as it appears in its source expression. For example, the string /// representation of a floating point literal is subject to normalization, /// and string literals are returned without surrounding quotes. pub fn as_str(&self) -> Cow<'src, str> { match self { Literal::String(s) => s.clone(), Literal::Number(n) => Cow::Owned(n.to_string()), Literal::Boolean(b) => Cow::Owned(b.to_string()), Literal::Null => Cow::Borrowed("null"), } } /// Returns the trivial constant evaluation of the literal. pub(crate) fn consteval(&self) -> Evaluation { match self { Literal::String(s) => Evaluation::String(s.to_string()), Literal::Number(n) => Evaluation::Number(*n), Literal::Boolean(b) => Evaluation::Boolean(*b), Literal::Null => Evaluation::Null, } } } #[cfg(test)] mod tests { use anyhow::Result; use crate::Expr; #[test] fn test_evaluate_constant_literals() -> Result<()> { use crate::Evaluation; let test_cases = &[ ("'hello'", Evaluation::String("hello".to_string())), ("'world'", Evaluation::String("world".to_string())), ("42", Evaluation::Number(42.0)), ("3.14", Evaluation::Number(3.14)), ("true", Evaluation::Boolean(true)), ("false", Evaluation::Boolean(false)), ("null", Evaluation::Null), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } } github-actions-expressions-0.0.11/src/op.rs000064400000000000000000000047221046102023000167630ustar 00000000000000//! Unary and binary operators. /// Binary operations allowed in an expression. #[derive(Debug, PartialEq)] pub enum BinOp { /// `expr && expr` And, /// `expr || expr` Or, /// `expr == expr` Eq, /// `expr != expr` Neq, /// `expr > expr` Gt, /// `expr >= expr` Ge, /// `expr < expr` Lt, /// `expr <= expr` Le, } /// Unary operations allowed in an expression. #[derive(Debug, PartialEq)] pub enum UnOp { /// `!expr` Not, } #[cfg(test)] mod tests { use crate::Expr; use anyhow::Result; #[test] fn test_evaluate_constant_binary_operations() -> Result<()> { use crate::Evaluation; let test_cases = &[ // Boolean operations ("true && true", Evaluation::Boolean(true)), ("true && false", Evaluation::Boolean(false)), ("false && true", Evaluation::Boolean(false)), ("false && false", Evaluation::Boolean(false)), ("true || true", Evaluation::Boolean(true)), ("true || false", Evaluation::Boolean(true)), ("false || true", Evaluation::Boolean(true)), ("false || false", Evaluation::Boolean(false)), // Equality operations ("1 == 1", Evaluation::Boolean(true)), ("1 == 2", Evaluation::Boolean(false)), ("'hello' == 'hello'", Evaluation::Boolean(true)), ("'hello' == 'world'", Evaluation::Boolean(false)), ("true == true", Evaluation::Boolean(true)), ("true == false", Evaluation::Boolean(false)), ("1 != 2", Evaluation::Boolean(true)), ("1 != 1", Evaluation::Boolean(false)), // Comparison operations ("1 < 2", Evaluation::Boolean(true)), ("2 < 1", Evaluation::Boolean(false)), ("1 <= 1", Evaluation::Boolean(true)), ("1 <= 2", Evaluation::Boolean(true)), ("2 <= 1", Evaluation::Boolean(false)), ("2 > 1", Evaluation::Boolean(true)), ("1 > 2", Evaluation::Boolean(false)), ("1 >= 1", Evaluation::Boolean(true)), ("2 >= 1", Evaluation::Boolean(true)), ("1 >= 2", Evaluation::Boolean(false)), ]; for (expr_str, expected) in test_cases { let expr = Expr::parse(expr_str)?; let result = expr.consteval().unwrap(); assert_eq!(result, *expected, "Failed for expression: {}", expr_str); } Ok(()) } }