Skip to main content

arch_toolkit/deps/
pkgbuild.rs

1//! Parser for PKGBUILD files.
2//!
3//! This module provides functions for parsing PKGBUILD files, which are
4//! bash scripts that define how Arch Linux packages are built.
5//!
6//! The parser extracts dependency arrays (depends, makedepends, checkdepends, optdepends)
7//! and conflicts from PKGBUILD content, handling both single-line and multi-line
8//! bash array syntax.
9
10use std::collections::HashSet;
11
12use crate::deps::parse::parse_dep_spec;
13
14/// What: Parse dependencies from PKGBUILD content.
15///
16/// Inputs:
17/// - `pkgbuild`: Raw PKGBUILD file content.
18///
19/// Output:
20/// - Returns a tuple of (depends, makedepends, checkdepends, optdepends) vectors.
21///
22/// Details:
23/// - Parses bash array syntax: `depends=('foo' 'bar>=1.2')` (single-line)
24/// - Also handles `depends+=` patterns used in functions like `package()`
25/// - Handles both quoted and unquoted dependencies
26/// - Also handles multi-line arrays:
27///   ```text
28///   depends=(
29///       'foo'
30///       'bar>=1.2'
31///   )
32///   ```
33/// - Filters out .so files (virtual packages) and invalid package names
34/// - Only parses specific dependency fields (depends, makedepends, checkdepends, optdepends)
35/// - Deduplicates dependencies (returns unique list)
36#[allow(clippy::case_sensitive_file_extension_comparisons)]
37#[must_use]
38pub fn parse_pkgbuild_deps(pkgbuild: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
39    let lines: Vec<&str> = pkgbuild.lines().collect();
40    let mut index = 0;
41    let mut fields = DependencyFields::default();
42
43    while index < lines.len() {
44        let line = lines[index].trim();
45        index += 1;
46        let Some((key, value)) = dependency_declaration(line) else {
47            continue;
48        };
49        let values = parse_declared_array(value, &lines, &mut index);
50        fields.extend(key, values);
51    }
52
53    fields.into_parts()
54}
55
56/// What: Dependency field selected by a PKGBUILD array declaration.
57///
58/// Inputs:
59/// - Produced by [`dependency_declaration`].
60///
61/// Output:
62/// - Selects one of the four dependency vectors.
63///
64/// Details:
65/// - Keeps field dispatch separate from parsing so the main parser remains below complexity limits.
66#[derive(Clone, Copy)]
67enum DependencyField {
68    /// Runtime dependencies.
69    Runtime,
70    /// Build-time dependencies.
71    Make,
72    /// Test dependencies.
73    Check,
74    /// Optional runtime integrations.
75    Optional,
76}
77
78/// What: Accumulate parsed dependency fields while preserving insertion order.
79///
80/// Inputs:
81/// - Populated by [`DependencyFields::extend`].
82///
83/// Output:
84/// - Four deduplicated dependency vectors.
85///
86/// Details:
87/// - Each field owns an independent seen set because the same package may validly occur in
88///   different dependency categories.
89#[derive(Default)]
90struct DependencyFields {
91    /// Runtime dependencies and their deduplication set.
92    depends: (Vec<String>, HashSet<String>),
93    /// Build dependencies and their deduplication set.
94    makedepends: (Vec<String>, HashSet<String>),
95    /// Test dependencies and their deduplication set.
96    checkdepends: (Vec<String>, HashSet<String>),
97    /// Optional dependencies and their deduplication set.
98    optdepends: (Vec<String>, HashSet<String>),
99}
100
101impl DependencyFields {
102    /// What: Add valid, unique values to one dependency field.
103    ///
104    /// Inputs:
105    /// - `field`: Destination dependency category.
106    /// - `values`: Raw array values parsed from the declaration.
107    ///
108    /// Output:
109    /// - Updates this accumulator in declaration order.
110    ///
111    /// Details:
112    /// - Invalid names and shared-library dependency tokens retain the historical filtering policy.
113    fn extend(&mut self, field: DependencyField, values: Vec<String>) {
114        let destination = match field {
115            DependencyField::Runtime => &mut self.depends,
116            DependencyField::Make => &mut self.makedepends,
117            DependencyField::Check => &mut self.checkdepends,
118            DependencyField::Optional => &mut self.optdepends,
119        };
120        for value in values {
121            let value = value.trim();
122            if !value.is_empty() && is_valid_dependency(value) && destination.1.insert(value.into())
123            {
124                destination.0.push(value.into());
125            }
126        }
127    }
128
129    /// What: Consume the accumulator and return the public parser tuple.
130    ///
131    /// Inputs:
132    /// - `self`: Completed dependency accumulator.
133    ///
134    /// Output:
135    /// - Runtime, make, check, and optional dependency vectors in that order.
136    ///
137    /// Details:
138    /// - Preserves the existing public return type and ordering contract.
139    fn into_parts(self) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
140        (
141            self.depends.0,
142            self.makedepends.0,
143            self.checkdepends.0,
144            self.optdepends.0,
145        )
146    }
147}
148
149/// What: Parse a dependency-array declaration header.
150///
151/// Inputs:
152/// - `line`: Trimmed PKGBUILD source line.
153///
154/// Output:
155/// - The selected field and array value, or `None` for comments, blanks, and unrelated fields.
156///
157/// Details:
158/// - Supports both assignment and append forms such as `depends=` and `depends+=`.
159fn dependency_declaration(line: &str) -> Option<(DependencyField, &str)> {
160    if line.is_empty() || line.starts_with('#') {
161        return None;
162    }
163    let (key, value) = line.split_once('=')?;
164    let field = match key.trim().strip_suffix('+').unwrap_or_else(|| key.trim()) {
165        "depends" => DependencyField::Runtime,
166        "makedepends" => DependencyField::Make,
167        "checkdepends" => DependencyField::Check,
168        "optdepends" => DependencyField::Optional,
169        _ => return None,
170    };
171    value
172        .trim()
173        .starts_with('(')
174        .then_some((field, value.trim()))
175}
176
177/// What: Parse one single-line or multiline PKGBUILD array declaration.
178///
179/// Inputs:
180/// - `value`: Declaration text beginning with `(`.
181/// - `lines`: Complete PKGBUILD line list.
182/// - `index`: Cursor positioned after the declaration line.
183///
184/// Output:
185/// - Raw array values parsed by [`parse_array_content`].
186///
187/// Details:
188/// - Advances `index` through a multiline declaration and retains content before a closing `)`.
189fn parse_declared_array(value: &str, lines: &[&str], index: &mut usize) -> Vec<String> {
190    if let Some(closing) = find_matching_closing_paren(value) {
191        return parse_array_content(&value[1..closing]);
192    }
193
194    let mut parts = Vec::new();
195    let first = value[1..].trim();
196    if !first.is_empty() && !first.starts_with('#') {
197        parts.push(first.to_string());
198    }
199    while *index < lines.len() {
200        let line = lines[*index].trim();
201        *index += 1;
202        if line.is_empty() || line.starts_with('#') {
203            continue;
204        }
205        if line == ")" {
206            break;
207        }
208        if let Some(closing) = line.find(')') {
209            let content = line[..closing].trim();
210            if !content.is_empty() {
211                parts.push(content.to_string());
212            }
213            break;
214        }
215        parts.push(line.to_string());
216    }
217    parse_array_content(&parts.join(" "))
218}
219
220/// What: Parse conflicts from PKGBUILD content.
221///
222/// Inputs:
223/// - `pkgbuild`: Raw PKGBUILD file content.
224///
225/// Output:
226/// - Returns a vector of conflicting package names (without version constraints).
227///
228/// Details:
229/// - Parses bash array syntax: `conflicts=('foo' 'bar')` (single-line)
230/// - Also handles `conflicts+=` patterns used in functions like `package()`
231/// - Handles both quoted and unquoted conflicts
232/// - Also handles multi-line arrays:
233///   ```text
234///   conflicts=(
235///       'foo'
236///       'bar'
237///   )
238///   ```
239/// - Filters out .so files (virtual packages) and invalid package names
240/// - Extracts package names from version constraints (e.g., "jujutsu-git>=1.0" -> "jujutsu-git")
241/// - Deduplicates conflicts (returns unique list)
242#[allow(clippy::case_sensitive_file_extension_comparisons)]
243#[must_use]
244pub fn parse_pkgbuild_conflicts(pkgbuild: &str) -> Vec<String> {
245    let mut conflicts = Vec::new();
246    let mut seen = HashSet::new();
247
248    let lines: Vec<&str> = pkgbuild.lines().collect();
249    let mut i = 0;
250
251    while i < lines.len() {
252        let line = lines[i].trim();
253        i += 1;
254
255        if line.is_empty() || line.starts_with('#') {
256            continue;
257        }
258
259        // Parse array declarations: conflicts=('foo' 'bar') or conflicts=( or conflicts+=('foo' 'bar')
260        if let Some((key, value)) = line.split_once('=') {
261            let key = key.trim();
262            let value = value.trim();
263
264            // Handle both conflicts= and conflicts+= patterns
265            let base_key = key.strip_suffix('+').map_or(key, |stripped| stripped);
266
267            // Only parse conflicts field
268            if base_key != "conflicts" {
269                continue;
270            }
271
272            // Check if this is an array declaration
273            if value.starts_with('(') {
274                let conflict_deps = find_matching_closing_paren(value).map_or_else(
275                    || {
276                        // Multi-line array: conflicts=(
277                        //     'foo'
278                        //     'bar'
279                        // )
280                        let mut array_lines = Vec::new();
281                        // Collect lines until we find the closing parenthesis
282                        while i < lines.len() {
283                            let next_line = lines[i].trim();
284                            i += 1;
285
286                            // Skip empty lines and comments
287                            if next_line.is_empty() || next_line.starts_with('#') {
288                                continue;
289                            }
290
291                            // Check if this line closes the array
292                            if next_line == ")" {
293                                break;
294                            }
295
296                            // Check if this line contains a closing parenthesis (may be on same line as content)
297                            if let Some(paren_pos) = next_line.find(')') {
298                                // Extract content before the closing paren
299                                let content_before_paren = &next_line[..paren_pos].trim();
300                                if !content_before_paren.is_empty() {
301                                    array_lines.push((*content_before_paren).to_string());
302                                }
303                                break;
304                            }
305
306                            // Add this line to the array content
307                            array_lines.push(next_line.to_string());
308                        }
309
310                        // Parse all collected lines as array content
311                        let array_content = array_lines
312                            .iter()
313                            .map(|s| s.trim())
314                            .filter(|s| !s.is_empty())
315                            .collect::<Vec<_>>()
316                            .join(" ");
317                        parse_array_content(&array_content)
318                    },
319                    |closing_paren_pos| {
320                        // Single-line array (may have content after closing paren): conflicts=('foo' 'bar') or conflicts+=('foo' 'bar') other_code
321                        let array_content = &value[1..closing_paren_pos];
322                        parse_array_content(array_content)
323                    },
324                );
325
326                // Filter out invalid conflicts (.so files, invalid names, etc.)
327                let filtered_conflicts: Vec<String> = conflict_deps
328                    .into_iter()
329                    .filter_map(|conflict| {
330                        let conflict_trimmed = conflict.trim();
331                        if conflict_trimmed.is_empty() {
332                            return None;
333                        }
334
335                        if is_valid_dependency(conflict_trimmed) {
336                            // Extract package name (remove version constraints if present)
337                            // Use a simple approach: split on version operators
338                            let spec = parse_dep_spec(conflict_trimmed);
339                            if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
340                                Some(spec.name)
341                            } else {
342                                None
343                            }
344                        } else {
345                            None
346                        }
347                    })
348                    .collect();
349
350                // Add conflicts to the vector (using base_key to handle both = and +=)
351                conflicts.extend(filtered_conflicts);
352            }
353        }
354    }
355
356    conflicts
357}
358
359/// What: Find the position of the matching closing parenthesis in a string.
360///
361/// Inputs:
362/// - `s`: String starting with an opening parenthesis.
363///
364/// Output:
365/// - `Some(position)` if a matching closing parenthesis is found, `None` otherwise.
366///
367/// Details:
368/// - Handles nested parentheses and quoted strings.
369fn find_matching_closing_paren(s: &str) -> Option<usize> {
370    let mut depth = 0;
371    let mut in_quotes = false;
372    let mut quote_char = '\0';
373
374    for (pos, ch) in s.char_indices() {
375        match ch {
376            '\'' | '"' => {
377                if !in_quotes {
378                    in_quotes = true;
379                    quote_char = ch;
380                } else if ch == quote_char {
381                    in_quotes = false;
382                    quote_char = '\0';
383                }
384            }
385            '(' if !in_quotes => {
386                depth += 1;
387            }
388            ')' if !in_quotes => {
389                depth -= 1;
390                if depth == 0 {
391                    return Some(pos);
392                }
393            }
394            _ => {}
395        }
396    }
397    None
398}
399
400/// What: Parse quoted and unquoted strings from bash array content.
401///
402/// Inputs:
403/// - `content`: Array content string (e.g., "'foo' 'bar>=1.2'" or "libcairo.so libdbus-1.so").
404///
405/// Output:
406/// - Vector of dependency strings.
407///
408/// Details:
409/// - Handles both quoted ('foo') and unquoted (foo) dependencies.
410/// - Splits on whitespace for unquoted values.
411fn parse_array_content(content: &str) -> Vec<String> {
412    let mut deps = Vec::new();
413    let mut in_quotes = false;
414    let mut quote_char = '\0';
415    let mut current = String::new();
416
417    for ch in content.chars() {
418        match ch {
419            '\'' | '"' => {
420                if !in_quotes {
421                    in_quotes = true;
422                    quote_char = ch;
423                } else if ch == quote_char {
424                    if !current.is_empty() {
425                        deps.push(current.clone());
426                        current.clear();
427                    }
428                    in_quotes = false;
429                    quote_char = '\0';
430                } else {
431                    current.push(ch);
432                }
433            }
434            _ if in_quotes => {
435                current.push(ch);
436            }
437            ch if ch.is_whitespace() => {
438                // Whitespace outside quotes - end current unquoted value
439                if !current.is_empty() {
440                    deps.push(current.clone());
441                    current.clear();
442                }
443            }
444            _ => {
445                // Non-whitespace character outside quotes - add to current value
446                current.push(ch);
447            }
448        }
449    }
450
451    // Handle unclosed quote or trailing unquoted value
452    if !current.is_empty() {
453        deps.push(current);
454    }
455
456    deps
457}
458
459/// What: Check if a dependency string is valid (not a .so file, has valid format).
460///
461/// Inputs:
462/// - `dep`: Dependency string to validate.
463///
464/// Output:
465/// - Returns `true` if the dependency appears to be valid, `false` otherwise.
466///
467/// Details:
468/// - Filters out .so files (virtual packages)
469/// - Filters out names ending with ) (parsing errors)
470/// - Filters out names that don't start with alphanumeric or underscore
471/// - Filters out names that are too short (< 2 characters)
472/// - Requires at least one alphanumeric character
473fn is_valid_dependency(dep: &str) -> bool {
474    // Filter out .so files (virtual packages)
475    let dep_lower = dep.to_lowercase();
476    if std::path::Path::new(&dep_lower)
477        .extension()
478        .is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
479        || dep_lower.contains(".so.")
480        || dep_lower.contains(".so=")
481    {
482        return false;
483    }
484
485    // Filter out names ending with ) - this is a parsing error
486    // But first check if it's actually a valid name with version constraint ending in )
487    // like "package>=1.0)" which would be a parsing error
488    if dep.ends_with(')') {
489        // Check if it might be a valid version constraint that accidentally ends with )
490        // If it contains version operators before the ), it's likely a parsing error
491        if dep.contains(">=") || dep.contains("<=") || dep.contains("==") {
492            // This looks like "package>=1.0)" which is invalid
493            return false;
494        }
495        // Otherwise, it might be "package)" which is also invalid
496        return false;
497    }
498
499    // Filter out names that don't look like package names
500    // Package names should start with alphanumeric or underscore
501    let Some(first_char) = dep.chars().next() else {
502        return false;
503    };
504    if !first_char.is_alphanumeric() && first_char != '_' {
505        return false;
506    }
507
508    // Filter out names that are too short
509    if dep.len() < 2 {
510        return false;
511    }
512
513    // Filter out names containing invalid characters (but allow version operators)
514    // Allow: alphanumeric, dash, underscore, and version operators (>=, <=, ==, >, <)
515    let has_valid_chars = dep
516        .chars()
517        .any(|c| c.is_alphanumeric() || c == '-' || c == '_');
518    if !has_valid_chars {
519        return false;
520    }
521
522    true
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    // === parse_pkgbuild_deps tests ===
530
531    #[test]
532    fn test_parse_pkgbuild_deps_basic() {
533        let pkgbuild = r"
534pkgname=test-package
535pkgver=1.0.0
536depends=('foo' 'bar>=1.2')
537makedepends=('make' 'gcc')
538";
539
540        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
541
542        assert_eq!(depends.len(), 2);
543        assert!(depends.contains(&"foo".to_string()));
544        assert!(depends.contains(&"bar>=1.2".to_string()));
545
546        assert_eq!(makedepends.len(), 2);
547        assert!(makedepends.contains(&"make".to_string()));
548        assert!(makedepends.contains(&"gcc".to_string()));
549
550        assert_eq!(checkdepends.len(), 0);
551        assert_eq!(optdepends.len(), 0);
552    }
553
554    #[test]
555    fn test_parse_pkgbuild_deps_append() {
556        let pkgbuild = r#"
557pkgname=test-package
558pkgver=1.0.0
559package() {
560    depends+=(foo bar)
561    cd $_pkgname
562    make DESTDIR="$pkgdir" PREFIX=/usr install
563}
564"#;
565
566        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
567
568        assert_eq!(depends.len(), 2);
569        assert!(depends.contains(&"foo".to_string()));
570        assert!(depends.contains(&"bar".to_string()));
571
572        assert_eq!(makedepends.len(), 0);
573        assert_eq!(checkdepends.len(), 0);
574        assert_eq!(optdepends.len(), 0);
575    }
576
577    #[test]
578    fn test_parse_pkgbuild_deps_unquoted() {
579        let pkgbuild = r"
580pkgname=test-package
581depends=(foo bar libcairo.so libdbus-1.so)
582";
583
584        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
585
586        // .so files should be filtered out
587        assert_eq!(depends.len(), 2);
588        assert!(depends.contains(&"foo".to_string()));
589        assert!(depends.contains(&"bar".to_string()));
590
591        assert_eq!(makedepends.len(), 0);
592        assert_eq!(checkdepends.len(), 0);
593        assert_eq!(optdepends.len(), 0);
594    }
595
596    #[test]
597    fn test_parse_pkgbuild_deps_multiline() {
598        let pkgbuild = r"
599pkgname=test-package
600depends=(
601    'foo'
602    'bar>=1.2'
603    'baz'
604)
605";
606
607        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
608
609        assert_eq!(depends.len(), 3);
610        assert!(depends.contains(&"foo".to_string()));
611        assert!(depends.contains(&"bar>=1.2".to_string()));
612        assert!(depends.contains(&"baz".to_string()));
613
614        assert_eq!(makedepends.len(), 0);
615        assert_eq!(checkdepends.len(), 0);
616        assert_eq!(optdepends.len(), 0);
617    }
618
619    #[test]
620    fn test_parse_pkgbuild_deps_makedepends_append() {
621        let pkgbuild = r"
622pkgname=test-package
623build() {
624    makedepends+=(cmake ninja)
625    cmake -B build
626}
627";
628
629        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
630
631        assert_eq!(makedepends.len(), 2);
632        assert!(makedepends.contains(&"cmake".to_string()));
633        assert!(makedepends.contains(&"ninja".to_string()));
634
635        assert_eq!(depends.len(), 0);
636        assert_eq!(checkdepends.len(), 0);
637        assert_eq!(optdepends.len(), 0);
638    }
639
640    #[test]
641    fn test_parse_pkgbuild_deps_jujutsu_git_scenario() {
642        let pkgbuild = r"
643pkgname=jujutsu-git
644pkgver=0.1.0
645pkgdesc=Git-compatible VCS that is both simple and powerful
646url=https://github.com/martinvonz/jj
647license=(Apache-2.0)
648arch=(i686 x86_64 armv6h armv7h)
649depends=(
650    glibc
651    libc.so
652    libm.so
653)
654makedepends=(
655    libgit2
656    libgit2.so
657    libssh2
658    libssh2.so)
659    openssh
660    git)
661cargo
662checkdepends=()
663optdepends=()
664source=($pkgname::git+$url)
665";
666
667        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
668
669        // depends should only contain glibc, .so files filtered out
670        assert_eq!(depends.len(), 1);
671        assert!(depends.contains(&"glibc".to_string()));
672
673        // makedepends should contain libgit2, libssh2
674        // .so files are filtered out
675        // Note: openssh, git), and cargo are after the array closes, so they're not part of makedepends
676        assert_eq!(makedepends.len(), 2);
677        assert!(makedepends.contains(&"libgit2".to_string()));
678        assert!(makedepends.contains(&"libssh2".to_string()));
679
680        assert_eq!(checkdepends.len(), 0);
681        assert_eq!(optdepends.len(), 0);
682    }
683
684    #[test]
685    fn test_parse_pkgbuild_deps_ignore_other_fields() {
686        let pkgbuild = r"
687pkgname=test-package
688pkgver=1.0.0
689pkgdesc=Test package description
690url=https://example.com
691license=(MIT)
692arch=(x86_64)
693source=($pkgname-$pkgver.tar.gz)
694depends=(foo bar)
695makedepends=(make)
696";
697
698        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
699
700        // Only depends and makedepends should be parsed
701        assert_eq!(depends.len(), 2);
702        assert!(depends.contains(&"foo".to_string()));
703        assert!(depends.contains(&"bar".to_string()));
704
705        assert_eq!(makedepends.len(), 1);
706        assert!(makedepends.contains(&"make".to_string()));
707
708        assert_eq!(checkdepends.len(), 0);
709        assert_eq!(optdepends.len(), 0);
710    }
711
712    #[test]
713    fn test_parse_pkgbuild_deps_filter_invalid_names() {
714        // Test filtering of invalid names (using single-line format for reliability)
715        let pkgbuild = r"
716depends=('valid-package' 'invalid)' '=invalid' 'a' 'valid>=1.0')
717";
718
719        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
720
721        // Only valid package names should remain
722        // Note: 'invalid)' should be filtered out (ends with ))
723        // Note: '=invalid' should be filtered out (starts with =)
724        // Note: 'a' should be filtered out (too short)
725        // So we should have: valid-package and valid>=1.0
726        assert_eq!(depends.len(), 2);
727        assert!(depends.contains(&"valid-package".to_string()));
728        assert!(depends.contains(&"valid>=1.0".to_string()));
729
730        assert_eq!(makedepends.len(), 0);
731        assert_eq!(checkdepends.len(), 0);
732        assert_eq!(optdepends.len(), 0);
733    }
734
735    #[test]
736    fn test_parse_pkgbuild_deps_deduplicates() {
737        let pkgbuild = r"
738depends=('foo' 'bar' 'foo' 'baz' 'bar')
739";
740
741        let (depends, _, _, _) = parse_pkgbuild_deps(pkgbuild);
742        assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
743        assert!(depends.contains(&"foo".to_string()));
744        assert!(depends.contains(&"bar".to_string()));
745        assert!(depends.contains(&"baz".to_string()));
746    }
747
748    #[test]
749    fn test_parse_pkgbuild_deps_empty() {
750        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps("");
751        assert_eq!(depends.len(), 0);
752        assert_eq!(makedepends.len(), 0);
753        assert_eq!(checkdepends.len(), 0);
754        assert_eq!(optdepends.len(), 0);
755    }
756
757    #[test]
758    fn test_parse_pkgbuild_deps_comments_and_blank_lines() {
759        let pkgbuild = r"
760# This is a comment
761pkgname=test-package
762
763depends=(foo bar)
764# Another comment
765makedepends=(make)
766";
767
768        let (depends, makedepends, _, _) = parse_pkgbuild_deps(pkgbuild);
769        assert_eq!(depends.len(), 2);
770        assert!(depends.contains(&"foo".to_string()));
771        assert!(depends.contains(&"bar".to_string()));
772        assert_eq!(makedepends.len(), 1);
773        assert!(makedepends.contains(&"make".to_string()));
774    }
775
776    #[test]
777    fn test_parse_pkgbuild_deps_mixed_quoted_unquoted() {
778        let pkgbuild = r"
779depends=('quoted' unquoted 'another-quoted' unquoted2)
780";
781
782        let (depends, _, _, _) = parse_pkgbuild_deps(pkgbuild);
783        assert_eq!(depends.len(), 4);
784        assert!(depends.contains(&"quoted".to_string()));
785        assert!(depends.contains(&"unquoted".to_string()));
786        assert!(depends.contains(&"another-quoted".to_string()));
787        assert!(depends.contains(&"unquoted2".to_string()));
788    }
789
790    // === parse_pkgbuild_conflicts tests ===
791
792    #[test]
793    fn test_parse_pkgbuild_conflicts_basic() {
794        let pkgbuild = r"
795pkgname=jujutsu-git
796pkgver=0.1.0
797conflicts=('jujutsu')
798";
799
800        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
801
802        assert_eq!(conflicts.len(), 1);
803        assert!(conflicts.contains(&"jujutsu".to_string()));
804    }
805
806    #[test]
807    fn test_parse_pkgbuild_conflicts_multiline() {
808        let pkgbuild = r"
809pkgname=pacsea-git
810pkgver=0.1.0
811conflicts=(
812    'pacsea'
813    'pacsea-bin'
814)
815";
816
817        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
818
819        assert_eq!(conflicts.len(), 2);
820        assert!(conflicts.contains(&"pacsea".to_string()));
821        assert!(conflicts.contains(&"pacsea-bin".to_string()));
822    }
823
824    #[test]
825    fn test_parse_pkgbuild_conflicts_with_versions() {
826        let pkgbuild = r"
827pkgname=test-package
828conflicts=('old-pkg<2.0' 'new-pkg>=3.0')
829";
830
831        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
832
833        assert_eq!(conflicts.len(), 2);
834        assert!(conflicts.contains(&"old-pkg".to_string()));
835        assert!(conflicts.contains(&"new-pkg".to_string()));
836    }
837
838    #[test]
839    fn test_parse_pkgbuild_conflicts_filter_so() {
840        let pkgbuild = r"
841pkgname=test-package
842conflicts=('foo' 'libcairo.so' 'bar' 'libdbus-1.so=1-64')
843";
844
845        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
846
847        // .so files should be filtered out
848        assert_eq!(conflicts.len(), 2);
849        assert!(conflicts.contains(&"foo".to_string()));
850        assert!(conflicts.contains(&"bar".to_string()));
851    }
852
853    #[test]
854    fn test_parse_pkgbuild_conflicts_deduplicates() {
855        let pkgbuild = r"
856conflicts=('pkg1' 'pkg2' 'pkg1' 'pkg3')
857";
858
859        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
860        assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
861        assert!(conflicts.contains(&"pkg1".to_string()));
862        assert!(conflicts.contains(&"pkg2".to_string()));
863        assert!(conflicts.contains(&"pkg3".to_string()));
864    }
865
866    #[test]
867    fn test_parse_pkgbuild_conflicts_empty() {
868        let conflicts = parse_pkgbuild_conflicts("");
869        assert!(conflicts.is_empty());
870    }
871
872    #[test]
873    /// What: Verify multi-line arrays keep the entry on the declaration line.
874    ///
875    /// Inputs:
876    /// - Array whose first entry follows the opening parenthesis and whose
877    ///   remaining entries sit on continuation lines.
878    ///
879    /// Output:
880    /// - All entries parsed, including the one on the `optdepends=(` line.
881    ///
882    /// Details:
883    /// - Regression test: the multi-line branch used to discard content after
884    ///   the opening parenthesis on the declaration line.
885    fn test_multiline_array_keeps_first_line_entry() {
886        let pkgbuild = "optdepends=('cups: printing support'\n            'foo: extra feature')\ndepends=('glibc'\n         'gcc-libs'\n)";
887        let (depends, _makedepends, _checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
888        assert_eq!(
889            optdepends,
890            vec![
891                "cups: printing support".to_string(),
892                "foo: extra feature".to_string()
893            ]
894        );
895        assert_eq!(depends, vec!["glibc".to_string(), "gcc-libs".to_string()]);
896    }
897}