use crate::error::{DealerError, DealerResult};
use crate::project::dependency::{ParsedDependency, parse_dependency_line, tokenize_line};
pub(crate) struct ProjectDeclaration {
pub(crate) version: String,
pub(crate) xtazy_pin: Option<String>,
pub(crate) dependencies: Vec<ParsedDependency>,
pub(crate) fn parse_project_file(content: &str) -> DealerResult<ProjectDeclaration> {
let lines: Vec<&str> = content.lines().collect();
// Validate that every non-empty line (including comment-only lines) has no spaces in leading indentation
for (line_num, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
let first_non_ws = line.find(|c: char| !c.is_whitespace()).unwrap();
let leading = &line[..first_non_ws];
if leading.contains(' ') {
return Err(DealerError::Project(format!(
"line {}: invalid indentation: structural indentation must use tabs only, spaces are not allowed",
// Find first non-empty line to get the app/package declaration
for (i, line) in lines.iter().enumerate() {
if !line.trim().is_empty() {
let decl_idx = decl_idx.ok_or_else(|| DealerError::Project("empty file".to_string()))?;
let decl_line = lines[decl_idx].trim();
let decl_tokens = tokenize_line(decl_line);
if decl_tokens.len() < 3 {
return Err(DealerError::Project(
"invalid project declaration: project declaration requires a version".to_string(),
let is_app = match decl_tokens[0].as_str() {
return Err(DealerError::Project(
"project file must declare 'app' or 'package' on the first line".to_string(),
let name = decl_tokens[1].clone();
let version = decl_tokens[2].clone();
let mut body_indent = None;
for line in &lines[decl_idx + 1..] {
let code = strip_comments(line);
if code.trim().is_empty() {
body_indent = Some(count_leading_spaces_or_tabs(line));
let mut dealer_headers = Vec::new();
for (i, line) in lines.iter().enumerate().skip(decl_idx + 1) {
let code = strip_comments(line);
if code.trim().is_empty() {
let indent = count_leading_spaces_or_tabs(line);
let trimmed = code.trim();
if trimmed == "dealer" || (trimmed.starts_with("dealer ") && trimmed.contains("xtazy")) {
return Err(DealerError::Project(
"dealer block must be indented as a root child block".to_string(),
if body_indent.filter(|&bi| indent == bi).is_some() {
if dealer_headers.len() > 1 {
return Err(DealerError::Project(
"duplicate top-level dealer block found".to_string(),
let mut xtazy_pin = None;
let mut dependencies = Vec::new();
if let Some(&h_idx) = dealer_headers.first() {
let h_indent = body_indent.unwrap();
let code = strip_comments(lines[h_idx]);
let trimmed = code.trim();
let tokens = tokenize_line(trimmed);
if tokens.len() == 3 && tokens[1] == "xtazy" {
xtazy_pin = Some(tokens[2].clone());
for line in &lines[h_idx + 1..] {
let code = strip_comments(line);
if code.trim().is_empty() {
let indent = count_leading_spaces_or_tabs(line);
let trimmed = code.trim();
if let Some(dep) = parse_dependency_line(trimmed) {
return Err(DealerError::Project(format!(
"invalid dependency declaration in dealer block: '{}'",
pub(crate) fn count_leading_spaces_or_tabs(s: &str) -> usize {
pub(crate) fn strip_comments(line: &str) -> &str {
let mut in_quotes = false;
let mut comment_byte_idx = None;
let bytes = line.as_bytes();
for (char_idx, ch) in line.char_indices() {
|| (ch == '/' && char_idx + 1 < bytes.len() && bytes[char_idx + 1] == b'/'))
comment_byte_idx = Some(char_idx);
if let Some(idx) = comment_byte_idx {