Skip to main content

panache_parser/parser/utils/
chunk_options.rs

1//! Chunk option value classification for Quarto/RMarkdown code blocks.
2//!
3//! This module distinguishes between simple literal values (booleans, numbers, strings)
4//! and complex R expressions (function calls, variables, etc.) to determine which
5//! chunk options can be safely converted to hashpipe format.
6
7/// Classification of chunk option values for conversion to hashpipe format.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ChunkOptionValue {
10    /// Simple literal value that can be safely converted to YAML syntax.
11    /// Examples: TRUE, FALSE, 7, "string"
12    Simple(String),
13
14    /// Complex R expression that should stay in inline format.
15    /// Examples: paste(...), my_var, nrow(data)
16    Expression(String),
17}
18
19/// Get hashpipe comment prefix for a code chunk language.
20pub fn hashpipe_comment_prefix(language: &str) -> Option<&'static str> {
21    match language.to_ascii_lowercase().as_str() {
22        "r" | "python" | "julia" | "bash" | "shell" | "sh" | "ruby" | "perl" => Some("#|"),
23        "c" | "cpp" | "c++" | "rcpp" | "java" | "javascript" | "js" | "typescript" | "ts"
24        | "rust" | "go" | "swift" | "kotlin" | "scala" | "csharp" | "c#" | "php" | "ojs"
25        | "dot" => Some("//|"),
26        "sql" | "mysql" | "postgres" | "postgresql" | "sqlite" => Some("--|"),
27        "mermaid" => Some("%%|"),
28        _ => None,
29    }
30}
31
32/// Classify a chunk option value as either simple (convertible) or expression (skip).
33///
34/// Conservative approach: only classify as Simple if we're certain it's a literal.
35/// When in doubt, classify as Expression to avoid breaking R code.
36///
37/// **Note**: The parser strips quotes from values, so we receive the inner string.
38/// For `label="my chunk"`, value is `"my chunk"` (no quotes).
39pub fn classify_value(value: &Option<String>) -> ChunkOptionValue {
40    match value {
41        None => ChunkOptionValue::Simple(String::new()), // Bare flag like `echo` is treated as true
42        Some(v) => {
43            // Parser strips quotes, so we get the inner value
44            // Check if it looks like an R expression
45            if is_boolean_literal(v) || is_numeric_literal(v) || is_simple_string(v) {
46                ChunkOptionValue::Simple(v.clone())
47            } else {
48                ChunkOptionValue::Expression(v.clone())
49            }
50        }
51    }
52}
53
54/// Check if a string value is simple enough to be safely formatted.
55///
56/// Returns false for strings that look like R expressions (function calls, operators, variables).
57fn is_simple_string(s: &str) -> bool {
58    // Empty strings are simple
59    if s.is_empty() {
60        return true;
61    }
62
63    // If it contains R expression characters, it's complex
64    if s.contains('(')
65        || s.contains(')')
66        || s.contains('{')
67        || s.contains('}')
68        || s.contains('$')
69        || s.contains('[')
70        || s.contains(']')
71        || s.contains('+')
72        || s.contains('-')
73        || s.contains('*')
74        || s.contains('/')
75        || s.contains('<')
76        || s.contains('>')
77        || s.contains('!')
78        || s.contains(':')
79    {
80        return false;
81    }
82
83    // If it's a single bareword (could be a variable), it's complex
84    // unless it contains spaces or special chars (then it's a string literal)
85    if !s.contains(' ')
86        && !s.contains('.')
87        && !s.contains('/')
88        && !s.contains('\\')
89        && !s.contains(',')
90        && s.chars().all(|c| c.is_alphanumeric() || c == '_')
91    {
92        // Looks like a variable name
93        return false;
94    }
95
96    // Otherwise, treat as simple string (phrases, paths with dots/slashes)
97    true
98}
99
100/// Check if a string is an R boolean literal.
101///
102/// Accepts: TRUE, FALSE, T, F (R's boolean constants)
103pub fn is_boolean_literal(s: &str) -> bool {
104    matches!(s, "TRUE" | "FALSE" | "T" | "F")
105}
106
107/// Check if a string is a numeric literal.
108///
109/// Accepts: integers (7, -3) and floats (3.14, -2.5, 1e-5)
110pub fn is_numeric_literal(s: &str) -> bool {
111    // Try parsing as f64 to catch integers and floats
112    s.parse::<f64>().is_ok()
113}
114
115/// Check if a string is a quoted string literal.
116///
117/// Accepts both single and double quoted strings.
118/// Does not validate escape sequences - just checks for matching quotes.
119pub fn is_quoted_string(s: &str) -> bool {
120    (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
121        || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_is_boolean_literal() {
130        assert!(is_boolean_literal("TRUE"));
131        assert!(is_boolean_literal("FALSE"));
132        assert!(is_boolean_literal("T"));
133        assert!(is_boolean_literal("F"));
134
135        assert!(!is_boolean_literal("true"));
136        assert!(!is_boolean_literal("false"));
137        assert!(!is_boolean_literal("True"));
138        assert!(!is_boolean_literal("MAYBE"));
139    }
140
141    #[test]
142    fn test_is_numeric_literal() {
143        // Integers
144        assert!(is_numeric_literal("7"));
145        assert!(is_numeric_literal("0"));
146        assert!(is_numeric_literal("-3"));
147        assert!(is_numeric_literal("100"));
148
149        // Floats
150        assert!(is_numeric_literal("3.14"));
151        assert!(is_numeric_literal("-2.5"));
152        assert!(is_numeric_literal("0.1"));
153
154        // Scientific notation
155        assert!(is_numeric_literal("1e5"));
156        assert!(is_numeric_literal("1.5e-3"));
157
158        // Not numeric
159        assert!(!is_numeric_literal("abc"));
160        assert!(!is_numeric_literal("7x"));
161        assert!(!is_numeric_literal(""));
162    }
163
164    #[test]
165    fn test_is_quoted_string() {
166        // Double quotes
167        assert!(is_quoted_string("\"hello\""));
168        assert!(is_quoted_string("\"with spaces\""));
169        assert!(is_quoted_string("\"\""));
170
171        // Single quotes
172        assert!(is_quoted_string("'hello'"));
173        assert!(is_quoted_string("'with spaces'"));
174        assert!(is_quoted_string("''"));
175
176        // Not quoted
177        assert!(!is_quoted_string("hello"));
178        assert!(!is_quoted_string("\""));
179        assert!(!is_quoted_string("'"));
180        assert!(!is_quoted_string("\"hello'"));
181        assert!(!is_quoted_string("'hello\""));
182        assert!(!is_quoted_string(""));
183    }
184
185    #[test]
186    fn test_classify_boolean() {
187        let result = classify_value(&Some("TRUE".to_string()));
188        assert_eq!(result, ChunkOptionValue::Simple("TRUE".to_string()));
189
190        let result = classify_value(&Some("FALSE".to_string()));
191        assert_eq!(result, ChunkOptionValue::Simple("FALSE".to_string()));
192    }
193
194    #[test]
195    fn test_classify_number() {
196        let result = classify_value(&Some("7".to_string()));
197        assert_eq!(result, ChunkOptionValue::Simple("7".to_string()));
198
199        let result = classify_value(&Some("3.14".to_string()));
200        assert_eq!(result, ChunkOptionValue::Simple("3.14".to_string()));
201    }
202
203    #[test]
204    fn test_classify_quoted_string() {
205        let result = classify_value(&Some("\"hello\"".to_string()));
206        assert_eq!(result, ChunkOptionValue::Simple("\"hello\"".to_string()));
207
208        let result = classify_value(&Some("'world'".to_string()));
209        assert_eq!(result, ChunkOptionValue::Simple("'world'".to_string()));
210    }
211
212    #[test]
213    fn test_classify_function_call() {
214        let result = classify_value(&Some("paste(\"a\", \"b\")".to_string()));
215        assert_eq!(
216            result,
217            ChunkOptionValue::Expression("paste(\"a\", \"b\")".to_string())
218        );
219    }
220
221    #[test]
222    fn test_classify_variable() {
223        let result = classify_value(&Some("my_var".to_string()));
224        assert_eq!(result, ChunkOptionValue::Expression("my_var".to_string()));
225    }
226
227    #[test]
228    fn test_classify_none() {
229        let result = classify_value(&None);
230        assert_eq!(result, ChunkOptionValue::Simple(String::new()));
231    }
232
233    #[test]
234    fn test_classify_expression_with_operators() {
235        let result = classify_value(&Some("x + y".to_string()));
236        assert_eq!(result, ChunkOptionValue::Expression("x + y".to_string()));
237
238        let result = classify_value(&Some("data$col".to_string()));
239        assert_eq!(result, ChunkOptionValue::Expression("data$col".to_string()));
240
241        let result = classify_value(&Some("vec[1]".to_string()));
242        assert_eq!(result, ChunkOptionValue::Expression("vec[1]".to_string()));
243    }
244
245    #[test]
246    fn test_hashpipe_comment_prefix() {
247        assert_eq!(hashpipe_comment_prefix("r"), Some("#|"));
248        assert_eq!(hashpipe_comment_prefix("cpp"), Some("//|"));
249        assert_eq!(hashpipe_comment_prefix("Rcpp"), Some("//|"));
250        assert_eq!(hashpipe_comment_prefix("sql"), Some("--|"));
251        assert_eq!(hashpipe_comment_prefix("mermaid"), Some("%%|"));
252        assert_eq!(hashpipe_comment_prefix("fortran"), None);
253    }
254}