use super::cirru_validator;
use cirru_parser::Cirru;
use std::fs;
use std::sync::Arc;
pub const ERR_MULTIPLE_INPUT_SOURCES: &str = "Multiple input sources provided. Use only one of: --file/-f, --code/-e, or --json/-j.";
pub const ERR_CONFLICTING_INPUT_FLAGS: &str = "Conflicting input flags: --leaf cannot be used with --json-input.";
pub const ERR_CODE_INPUT_REQUIRED: &str = "Code input required: use --file, --code, or --json";
pub const ERR_JSON_OBJECTS_NOT_SUPPORTED: &str = "JSON objects not supported, use arrays";
pub fn json_value_to_cirru(json: &serde_json::Value) -> Result<Cirru, String> {
match json {
serde_json::Value::String(s) => Ok(Cirru::Leaf(Arc::from(s.as_str()))),
serde_json::Value::Number(n) => Ok(Cirru::Leaf(Arc::from(n.to_string()))),
serde_json::Value::Bool(b) => Ok(Cirru::Leaf(Arc::from(b.to_string()))),
serde_json::Value::Null => Ok(Cirru::Leaf(Arc::from("nil"))),
serde_json::Value::Array(arr) => {
let items: Result<Vec<Cirru>, String> = arr.iter().map(json_value_to_cirru).collect();
Ok(Cirru::List(items?))
}
serde_json::Value::Object(_) => Err(ERR_JSON_OBJECTS_NOT_SUPPORTED.to_string()),
}
}
pub fn json_to_cirru(json_str: &str) -> Result<Cirru, String> {
let json_value: serde_json::Value = serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {e}"))?;
json_value_to_cirru(&json_value)
}
pub fn cirru_to_json_value(c: &Cirru) -> serde_json::Value {
match c {
Cirru::Leaf(s) => serde_json::Value::String(s.to_string()),
Cirru::List(items) => serde_json::Value::Array(items.iter().map(cirru_to_json_value).collect()),
}
}
pub fn cirru_to_json(node: &Cirru) -> String {
serde_json::to_string_pretty(&cirru_to_json_value(node)).unwrap_or_else(|_| "[]".to_string())
}
pub fn format_path_with_separator(path: &[usize], separator: &str) -> String {
path.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(separator)
}
pub fn format_path(path: &[usize]) -> String {
format_path_with_separator(path, ".")
}
fn is_shell_sensitive_char(ch: char) -> bool {
matches!(
ch,
'>' | '<' | '|' | '&' | ';' | '(' | ')' | '$' | '*' | '?' | '[' | ']' | '{' | '}' | '!' | '`'
)
}
pub fn shell_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\'', "'\"'\"'"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefinitionLookup {
pub resolved: String,
pub warning: Option<String>,
}
pub fn resolve_definition_lookup<'a, I>(
namespace: &str,
requested: &str,
definitions: I,
auto_correct: bool,
) -> Result<DefinitionLookup, String>
where
I: IntoIterator<Item = &'a str>,
{
let definition_names: Vec<&str> = definitions.into_iter().collect();
if definition_names.contains(&requested) {
return Ok(DefinitionLookup {
resolved: requested.to_string(),
warning: None,
});
}
let shell_candidates: Vec<(&str, char)> = definition_names
.into_iter()
.filter_map(|candidate| {
let rest = candidate.strip_prefix(requested)?;
let next_char = rest.chars().next()?;
if is_shell_sensitive_char(next_char) {
Some((candidate, next_char))
} else {
None
}
})
.collect();
if shell_candidates.is_empty() {
return Err(format!("Definition '{requested}' not found in namespace '{namespace}'"));
}
let mut lines = vec![format!("Definition '{requested}' not found in namespace '{namespace}'.")];
lines.push("Possible cause: your shell may have interpreted part of the definition name before calcit received it.".to_string());
lines.push("This often happens with characters like >, <, |, &, $, *, ?, (, or ).".to_string());
if shell_candidates.len() == 1 {
let (candidate, shell_char) = shell_candidates[0];
lines.push(format!(
"Detected a likely intended definition: '{candidate}' (the next character after '{requested}' is shell-sensitive: '{shell_char}')."
));
lines.push(format!(
"Try quoting the full target, for example: {}",
shell_quote(&format!("{namespace}/{candidate}"))
));
if auto_correct {
lines.push(format!("Auto-correcting to '{candidate}' for this read-only command."));
return Ok(DefinitionLookup {
resolved: candidate.to_string(),
warning: Some(lines.join("\n")),
});
}
} else {
let preview = shell_candidates
.iter()
.take(4)
.map(|(candidate, _)| format!("'{candidate}'"))
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
"Found multiple shell-sensitive candidates starting with '{requested}': {preview}"
));
lines.push(format!(
"Quote the full target to disambiguate, for example: {}",
shell_quote(&format!("{namespace}/{}", shell_candidates[0].0))
));
}
Err(lines.join("\n"))
}
pub fn print_cli_warning_block(message: &str) {
let mut lines = message.lines();
if let Some(first) = lines.next() {
eprintln!("\n⚠️ Warning: {first}");
for line in lines {
eprintln!(" {line}");
}
eprintln!();
}
}
pub fn emit_cli_output(content: &str, to_stderr: bool) {
if to_stderr {
eprint!("{content}");
if !content.ends_with('\n') {
eprintln!();
}
} else {
print!("{content}");
if !content.ends_with('\n') {
println!();
}
}
}
pub fn format_path_bracketed(path: &[usize]) -> String {
if path.is_empty() {
"root".to_string()
} else {
format!("[{}]", format_path(path))
}
}
pub fn parse_path(path_str: &str) -> Result<Vec<usize>, String> {
if path_str.is_empty() {
return Ok(vec![]);
}
if path_str.contains(',') {
return Err(format!(
"Invalid path '{path_str}': comma separator is no longer supported. Use dot-separated coordinates, e.g. '2.1.0'."
));
}
path_str
.split('.')
.map(|s| s.trim().parse::<usize>().map_err(|e| format!("Invalid path index '{s}': {e}")))
.collect()
}
pub fn validate_input_flags(leaf_input: bool, json_input: bool) -> Result<(), String> {
if leaf_input && json_input {
return Err(ERR_CONFLICTING_INPUT_FLAGS.to_string());
}
Ok(())
}
pub fn validate_input_sources(sources: &[bool]) -> Result<(), String> {
if sources.iter().filter(|&&enabled| enabled).count() > 1 {
Err(ERR_MULTIPLE_INPUT_SOURCES.to_string())
} else {
Ok(())
}
}
pub fn read_code_input(file: &Option<String>, code: &Option<String>, json: &Option<String>) -> Result<Option<String>, String> {
let sources = [file.is_some(), code.is_some(), json.is_some()];
validate_input_sources(&sources)?;
if let Some(path) = file {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file '{path}': {e}"))?;
Ok(Some(content.trim().to_string()))
} else if let Some(s) = code {
if s.contains('\n') {
eprintln!("\n⚠️ Note: Inline code contains newlines. Multi-line code in shell can be error-prone.");
eprintln!(" Consider writing to a temporary file and using --file/-f instead.");
eprintln!();
}
Ok(Some(s.trim().to_string()))
} else if let Some(j) = json {
Ok(Some(j.clone()))
} else {
Ok(None)
}
}
pub fn warn_if_single_string_expression(node: &Cirru, input_source: &str) {
if let Cirru::List(items) = node {
if items.len() == 1 {
if let Some(Cirru::Leaf(_)) = items.first() {
eprintln!("\n⚠️ Note: Cirru one-liner input '{input_source}' was parsed as an expression (list with one element).");
eprintln!(" In Cirru syntax, this creates a list containing one element.");
eprintln!(" If you want a leaf node (plain string), use --leaf parameter.");
eprintln!(" Example: --leaf -e '{input_source}' creates a leaf, not an expression.\n");
}
}
}
}
fn warn_if_wrapped_by_parentheses(raw: &str, node: &Cirru) {
let t = raw.trim();
if t.starts_with('(') && t.ends_with(')') {
eprintln!("\n⚠️ Warning: One-liner input appears wrapped by top-level parentheses.");
eprintln!(" Cirru typically avoids wrapping the entire top-level expression with '()'.");
eprintln!(" This extra layer changes call semantics. Prefer removing the outer parentheses.\n");
eprintln!(" JSON echo:");
eprintln!("{}", cirru_to_json(node));
eprintln!();
}
}
pub fn parse_input_to_cirru(
raw: &str,
inline_json: &Option<String>,
json_input: bool,
leaf: bool,
auto_json: bool,
) -> Result<Cirru, String> {
validate_input_flags(leaf, json_input)?;
if let Some(j) = inline_json {
if j.len() > 2000 {
eprintln!("\n⚠️ Note: JSON input is very large ({} chars).", j.len());
eprintln!(" For large definitions, consider using placeholders and submitting in segments.");
eprintln!();
}
let node = json_to_cirru(j)?;
if leaf {
match node {
Cirru::Leaf(_) => Ok(node),
_ => Err("--leaf expects a JSON string (leaf node), but got a non-leaf JSON value.".to_string()),
}
} else {
Ok(node)
}
} else if leaf {
Ok(Cirru::Leaf(Arc::from(raw)))
} else if json_input {
if raw.len() > 2000 {
eprintln!("\n⚠️ Note: JSON input is very large ({} chars).", raw.len());
eprintln!(" For large definitions, consider using placeholders and submitting in segments.");
eprintln!();
}
json_to_cirru(raw)
} else {
if auto_json {
let trimmed = raw.trim();
let looks_like_json_string = trimmed.starts_with('"') && trimmed.ends_with('"');
let looks_like_json_array = trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.contains('"');
if looks_like_json_array || looks_like_json_string {
if trimmed.len() > 2000 {
eprintln!("\n⚠️ Note: JSON input is very large ({} chars).", trimmed.len());
eprintln!(" For large definitions, consider using placeholders and submitting in segments.");
eprintln!();
}
return json_to_cirru(trimmed).map_err(|e| format!("Failed to parse JSON from -e/--code: {e}"));
}
if trimmed.is_empty() {
return Err("Input is empty. Please provide Cirru code or use -j for JSON input.".to_string());
}
if raw.contains('\t') {
return Err(
"Input contains tab characters. Cirru requires spaces for indentation.\n\
Please replace tabs with 2 spaces.\n\
Tip: Use `cat -A file` to check for tabs (shown as ^I)."
.to_string(),
);
}
if raw.len() > 1000 {
eprintln!("\n⚠️ Note: Cirru one-liner input is very large ({} chars).", raw.len());
eprintln!(" For large definitions, consider using placeholders and submitting in segments.");
eprintln!();
}
let result = cirru_parser::parse_expr_one_liner(raw).map_err(|e| format!("Failed to parse Cirru one-liner expression: {e}"))?;
warn_if_wrapped_by_parentheses(raw, &result);
warn_if_single_string_expression(&result, raw);
cirru_validator::validate_cirru_syntax(&result)?;
return Ok(result);
}
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("Input is empty. Please provide Cirru code or use -j for JSON input.".to_string());
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let after_bracket = &trimmed[1..];
let is_likely_json = after_bracket.starts_with('"')
|| after_bracket.starts_with(' ') && after_bracket.trim_start().starts_with('"')
|| after_bracket.starts_with('\n') && after_bracket.trim_start().starts_with('"');
let is_cirru_list = after_bracket.starts_with(']') || (after_bracket.starts_with(' ') && !after_bracket.trim_start().starts_with('"'));
if is_likely_json && !is_cirru_list {
return Err(
"Input appears to be JSON format (starts with '[\"').\n\
If you want to use JSON input, use one of:\n\
- inline JSON: cr edit def ns/name -j '[\"defn\", ...]'\n\
- inline code: cr edit def ns/name -e '[\"defn\", ...]'\n\
- file JSON: add -J or --json-input (e.g. -f code.json -J).\n\
Note: Cirru's [] list syntax (e.g. '[] 1 2 3') is different and will be parsed correctly."
.to_string(),
);
}
}
if raw.contains('\t') {
return Err(
"Input contains tab characters. Cirru requires spaces for indentation.\n\
Please replace tabs with 2 spaces.\n\
Tip: Use `cat -A file` to check for tabs (shown as ^I)."
.to_string(),
);
}
let parsed = cirru_parser::parse(raw).map_err(|e| {
let err_str = e.to_string();
let mut msg = format!("Failed to parse Cirru text: {err_str}");
if err_str.contains("odd indentation") {
msg.push_str("\n\nCirru requires 2-space indentation. Each nesting level must use exactly 2 spaces.");
msg.push_str("\nExample:\n defn my-fn (x)\n &+ x 1");
} else if err_str.contains("unexpected end of file") {
msg.push_str("\n\nPossible cause: missing closing quotes or unclosed structural pattern.");
}
msg
})?;
if parsed.len() == 1 {
let result = parsed.into_iter().next().unwrap();
warn_if_single_string_expression(&result, raw);
cirru_validator::validate_cirru_syntax(&result)?;
Ok(result)
} else if parsed.is_empty() {
Err("Input parsed as an empty Cirru structure.".to_string())
} else {
for node in &parsed {
cirru_validator::validate_cirru_syntax(node)?;
}
Ok(Cirru::List(parsed))
}
}
}
#[cfg(test)]
mod tests {
use super::{format_path, format_path_bracketed, format_path_with_separator, parse_path, resolve_definition_lookup, shell_quote};
#[test]
fn rejects_comma_separated_paths() {
let err = parse_path("3,2,1").unwrap_err();
assert!(err.contains("comma separator is no longer supported"));
}
#[test]
fn parses_dot_separated_paths() {
assert_eq!(parse_path("3.2.1").unwrap(), vec![3, 2, 1]);
}
#[test]
fn rejects_mixed_separators() {
assert!(parse_path("3,2.1").is_err());
}
#[test]
fn formats_paths_with_dot_by_default() {
assert_eq!(format_path(&[3, 2, 1]), "3.2.1");
assert_eq!(format_path_bracketed(&[3, 2, 1]), "[3.2.1]");
assert_eq!(format_path_with_separator(&[3, 2, 1], ","), "3,2,1");
}
#[test]
fn quotes_shell_targets_with_single_quotes() {
assert_eq!(shell_quote("app.main/element->node"), "'app.main/element->node'");
}
#[test]
fn auto_corrects_unique_shell_truncated_definition() {
let lookup = resolve_definition_lookup("respo.render.html", "element-", vec!["element->node", "render-app"], true).unwrap();
assert_eq!(lookup.resolved, "element->node");
let warning = lookup.warning.unwrap();
assert!(warning.contains("Possible cause: your shell may have interpreted part of the definition name"));
assert!(warning.contains("Auto-correcting to 'element->node'"));
}
#[test]
fn keeps_plain_not_found_when_no_shell_candidate_exists() {
let err = resolve_definition_lookup("app.main", "missing", vec!["main", "helper"], false).unwrap_err();
assert_eq!(err, "Definition 'missing' not found in namespace 'app.main'");
}
#[test]
fn reports_ambiguous_shell_truncated_definition() {
let err = resolve_definition_lookup("app.main", "value-", vec!["value->text", "value->debug", "other"], false).unwrap_err();
assert!(err.contains("Found multiple shell-sensitive candidates starting with 'value-'"));
assert!(err.contains("'value->text'"));
assert!(err.contains("'value->debug'"));
}
}