From e1e55f8e6c99cfdb4f53d3e9cc12026641c42b7e Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 2 Jul 2026 12:56:12 -0700 Subject: [PATCH] fix(config): consume INI shell-quoting layer in parse_flags PlatformIO feeds build_flags through Python shlex, so the standard string-valued define idiom -DNAME="\"Demo\"" must be dequoted at INI parse time: quote delimiters group-and-strip, and \" (outside single quotes) is an escaped literal quote. Previously the outer quotes survived into the token and prepare_flags_for_exec's \"->" collapse turned the macro value into ""Demo"", failing every use site with 'unable to find string literal operator'. Backslashes not escaping a double quote stay literal so Windows paths are untouched. Reproduced with NightDriverStrip env:demo (build_src_flags -DPROJECT_NAME="\"Demo\"") in the #942 profiling harness. Closes #947 Part of #942 Co-Authored-By: Claude Fable 5 --- crates/fbuild-config/src/ini_parser/tests.rs | 30 +++++++++++++++++++ crates/fbuild-config/src/ini_parser/values.rs | 17 ++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/fbuild-config/src/ini_parser/tests.rs b/crates/fbuild-config/src/ini_parser/tests.rs index 2afca0ac..38d1a166 100644 --- a/crates/fbuild-config/src/ini_parser/tests.rs +++ b/crates/fbuild-config/src/ini_parser/tests.rs @@ -578,6 +578,36 @@ fn test_parse_flags() { ); } +/// FastLED/fbuild#947: the INI shell-quoting layer must be consumed here, +/// exactly once, so string-valued defines reach the compiler as a single +/// direct-exec argv element like `-DPROJECT_NAME="Demo"`. +#[test] +fn test_parse_flags_dequotes_shell_layer() { + // Outer-quoted escaped form (NightDriverStrip demo env). + assert_eq!( + parse_flags(r#"-DPROJECT_NAME="\"Demo\"" -DMATRIX_WIDTH=144"#), + vec![r#"-DPROJECT_NAME="Demo""#, "-DMATRIX_WIDTH=144"] + ); + // Bare escaped form (no outer quotes) — common in other projects. + assert_eq!(parse_flags(r#"-DFOO=\"Bar\""#), vec![r#"-DFOO="Bar""#]); + // Quoted values with spaces group into one token. + assert_eq!( + parse_flags(r#"-DGREETING="\"Hello World\"""#), + vec![r#"-DGREETING="Hello World""#] + ); + // Quote delimiters around paths are consumed; backslashes that are + // not escaping a double quote stay literal (Windows paths). + assert_eq!( + parse_flags(r#"-I"C:\Program Files\SDK" -Ifoo"#), + vec![r"-IC:\Program Files\SDK", "-Ifoo"] + ); + // Single-quoted regions are fully literal. + assert_eq!( + parse_flags(r#"-DMSG='\"x\"' -DY=1"#), + vec![r#"-DMSG=\"x\""#, "-DY=1"] + ); +} + #[test] fn test_parse_lib_deps() { assert_eq!( diff --git a/crates/fbuild-config/src/ini_parser/values.rs b/crates/fbuild-config/src/ini_parser/values.rs index 321ad08e..5c9f5c8e 100644 --- a/crates/fbuild-config/src/ini_parser/values.rs +++ b/crates/fbuild-config/src/ini_parser/values.rs @@ -22,6 +22,13 @@ pub(super) fn strip_inline_comment(s: &str) -> String { /// - Multi-line: one flag per line /// - `-D FLAG` → `-DFLAG` normalization /// - Preserves arguments for `-include`, `-I`, `-L`, etc. +/// - Consumes the INI shell-quoting layer (FastLED/fbuild#947): quote +/// delimiters group and are stripped, and `\"` (outside single quotes) +/// is an escaped literal `"`. PlatformIO feeds `build_flags` through +/// Python `shlex`, so `-DNAME="\"Demo\""` must emerge here as the +/// single direct-exec argv element `-DNAME="Demo"`. Backslashes not +/// escaping a double quote stay literal (Windows paths) — the one +/// deliberate divergence from POSIX shlex. pub(super) fn parse_flags(flags_str: &str) -> Vec { let mut result = Vec::new(); @@ -31,21 +38,23 @@ pub(super) fn parse_flags(flags_str: &str) -> Vec { continue; } - let chars = trimmed.chars(); + let mut chars = trimmed.chars().peekable(); let mut current = String::new(); let mut in_quotes = false; let mut quote_char = ' '; - for c in chars { + while let Some(c) = chars.next() { match c { + '\\' if chars.peek() == Some(&'"') && !(in_quotes && quote_char == '\'') => { + chars.next(); + current.push('"'); + } '"' | '\'' if !in_quotes => { in_quotes = true; quote_char = c; - current.push(c); } c if in_quotes && c == quote_char => { in_quotes = false; - current.push(c); } ' ' | '\t' if !in_quotes => { if !current.is_empty() {