debian_watch/
parse.rs

1use crate::lex::lex;
2use crate::types::*;
3use crate::SyntaxKind;
4use crate::SyntaxKind::*;
5use crate::DEFAULT_VERSION;
6use std::marker::PhantomData;
7use std::str::FromStr;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub struct ParseError(Vec<String>);
11
12impl std::fmt::Display for ParseError {
13    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
14        for err in &self.0 {
15            writeln!(f, "{}", err)?;
16        }
17        Ok(())
18    }
19}
20
21impl std::error::Error for ParseError {}
22
23/// Second, implementing the `Language` trait teaches rowan to convert between
24/// these two SyntaxKind types, allowing for a nicer SyntaxNode API where
25/// "kinds" are values from our `enum SyntaxKind`, instead of plain u16 values.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub(crate) enum Lang {}
28impl rowan::Language for Lang {
29    type Kind = SyntaxKind;
30    fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind {
31        unsafe { std::mem::transmute::<u16, SyntaxKind>(raw.0) }
32    }
33    fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind {
34        kind.into()
35    }
36}
37
38/// GreenNode is an immutable tree, which is cheap to change,
39/// but doesn't contain offsets and parent pointers.
40use rowan::GreenNode;
41
42/// You can construct GreenNodes by hand, but a builder
43/// is helpful for top-down parsers: it maintains a stack
44/// of currently in-progress nodes
45use rowan::GreenNodeBuilder;
46
47/// Thread-safe parse result that can be stored in incremental computation systems like Salsa.
48/// The type parameter T represents the root AST node type (e.g., WatchFile).
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Parse<T> {
51    /// The immutable green tree that can be shared across threads
52    green: GreenNode,
53    /// Parse errors encountered during parsing
54    errors: Vec<String>,
55    /// Phantom type to associate this parse result with a specific AST type
56    _ty: PhantomData<T>,
57}
58
59impl<T> Parse<T> {
60    /// Create a new parse result
61    pub(crate) fn new(green: GreenNode, errors: Vec<String>) -> Self {
62        Parse {
63            green,
64            errors,
65            _ty: PhantomData,
66        }
67    }
68
69    /// Get the green node
70    pub fn green(&self) -> &GreenNode {
71        &self.green
72    }
73
74    /// Get the parse errors
75    pub fn errors(&self) -> &[String] {
76        &self.errors
77    }
78
79    /// Check if there were any parse errors
80    pub fn is_ok(&self) -> bool {
81        self.errors.is_empty()
82    }
83}
84
85impl Parse<WatchFile> {
86    /// Get the root WatchFile node
87    pub fn tree(&self) -> WatchFile {
88        WatchFile::cast(SyntaxNode::new_root(self.green.clone()))
89            .expect("root node should be a WatchFile")
90    }
91}
92
93// The internal parse result used during parsing
94struct InternalParse {
95    green_node: GreenNode,
96    errors: Vec<String>,
97}
98
99fn parse(text: &str) -> InternalParse {
100    struct Parser {
101        /// input tokens, including whitespace,
102        /// in *reverse* order.
103        tokens: Vec<(SyntaxKind, String)>,
104        /// the in-progress tree.
105        builder: GreenNodeBuilder<'static>,
106        /// the list of syntax errors we've accumulated
107        /// so far.
108        errors: Vec<String>,
109    }
110
111    impl Parser {
112        fn parse_version(&mut self) -> Option<u32> {
113            let mut version = None;
114            if self.tokens.last() == Some(&(KEY, "version".to_string())) {
115                self.builder.start_node(VERSION.into());
116                self.bump();
117                self.skip_ws();
118                if self.current() != Some(EQUALS) {
119                    self.builder.start_node(ERROR.into());
120                    self.errors.push("expected `=`".to_string());
121                    self.bump();
122                    self.builder.finish_node();
123                } else {
124                    self.bump();
125                }
126                if self.current() != Some(VALUE) {
127                    self.builder.start_node(ERROR.into());
128                    self.errors
129                        .push(format!("expected value, got {:?}", self.current()));
130                    self.bump();
131                    self.builder.finish_node();
132                } else if let Some((_, value)) = self.tokens.last() {
133                    let version_str = value;
134                    match version_str.parse() {
135                        Ok(v) => {
136                            version = Some(v);
137                            self.bump();
138                        }
139                        Err(_) => {
140                            self.builder.start_node(ERROR.into());
141                            self.errors
142                                .push(format!("invalid version: {}", version_str));
143                            self.bump();
144                            self.builder.finish_node();
145                        }
146                    }
147                } else {
148                    self.builder.start_node(ERROR.into());
149                    self.errors.push("expected version value".to_string());
150                    self.builder.finish_node();
151                }
152                if self.current() != Some(NEWLINE) {
153                    self.builder.start_node(ERROR.into());
154                    self.errors.push("expected newline".to_string());
155                    self.bump();
156                    self.builder.finish_node();
157                } else {
158                    self.bump();
159                }
160                self.builder.finish_node();
161            }
162            version
163        }
164
165        fn parse_watch_entry(&mut self) -> bool {
166            self.skip_ws();
167            if self.current().is_none() {
168                return false;
169            }
170            if self.current() == Some(NEWLINE) {
171                self.bump();
172                return false;
173            }
174            self.builder.start_node(ENTRY.into());
175            self.parse_options_list();
176            for i in 0..4 {
177                if self.current() == Some(NEWLINE) {
178                    break;
179                }
180                if self.current() == Some(CONTINUATION) {
181                    self.bump();
182                    self.skip_ws();
183                    continue;
184                }
185                if self.current() != Some(VALUE) && self.current() != Some(KEY) {
186                    self.builder.start_node(ERROR.into());
187                    self.errors.push(format!(
188                        "expected value, got {:?} (i={})",
189                        self.current(),
190                        i
191                    ));
192                    if self.current().is_some() {
193                        self.bump();
194                    }
195                    self.builder.finish_node();
196                } else {
197                    self.bump();
198                }
199                self.skip_ws();
200            }
201            if self.current() != Some(NEWLINE) && self.current().is_some() {
202                self.builder.start_node(ERROR.into());
203                self.errors
204                    .push(format!("expected newline, not {:?}", self.current()));
205                if self.current().is_some() {
206                    self.bump();
207                }
208                self.builder.finish_node();
209            } else {
210                self.bump();
211            }
212            self.builder.finish_node();
213            true
214        }
215
216        fn parse_option(&mut self) -> bool {
217            if self.current().is_none() {
218                return false;
219            }
220            while self.current() == Some(CONTINUATION) {
221                self.bump();
222            }
223            if self.current() == Some(WHITESPACE) {
224                return false;
225            }
226            self.builder.start_node(OPTION.into());
227            if self.current() != Some(KEY) {
228                self.builder.start_node(ERROR.into());
229                self.errors.push("expected key".to_string());
230                self.bump();
231                self.builder.finish_node();
232            } else {
233                self.bump();
234            }
235            if self.current() == Some(EQUALS) {
236                self.bump();
237                if self.current() != Some(VALUE) && self.current() != Some(KEY) {
238                    self.builder.start_node(ERROR.into());
239                    self.errors
240                        .push(format!("expected value, got {:?}", self.current()));
241                    self.bump();
242                    self.builder.finish_node();
243                } else {
244                    self.bump();
245                }
246            } else if self.current() == Some(COMMA) {
247            } else {
248                self.builder.start_node(ERROR.into());
249                self.errors.push("expected `=`".to_string());
250                if self.current().is_some() {
251                    self.bump();
252                }
253                self.builder.finish_node();
254            }
255            self.builder.finish_node();
256            true
257        }
258
259        fn parse_options_list(&mut self) {
260            self.skip_ws();
261            if self.tokens.last() == Some(&(KEY, "opts".to_string()))
262                || self.tokens.last() == Some(&(KEY, "options".to_string()))
263            {
264                self.builder.start_node(OPTS_LIST.into());
265                self.bump();
266                self.skip_ws();
267                if self.current() != Some(EQUALS) {
268                    self.builder.start_node(ERROR.into());
269                    self.errors.push("expected `=`".to_string());
270                    if self.current().is_some() {
271                        self.bump();
272                    }
273                    self.builder.finish_node();
274                } else {
275                    self.bump();
276                }
277                let quoted = if self.current() == Some(QUOTE) {
278                    self.bump();
279                    true
280                } else {
281                    false
282                };
283                loop {
284                    if quoted {
285                        if self.current() == Some(QUOTE) {
286                            self.bump();
287                            break;
288                        }
289                        self.skip_ws();
290                    }
291                    if !self.parse_option() {
292                        break;
293                    }
294                    if self.current() == Some(COMMA) {
295                        self.bump();
296                    } else if !quoted {
297                        break;
298                    }
299                }
300                self.builder.finish_node();
301                self.skip_ws();
302            }
303        }
304
305        fn parse(mut self) -> InternalParse {
306            // Make sure that the root node covers all source
307            self.builder.start_node(ROOT.into());
308            // Skip any leading comments/whitespace/newlines before version
309            while self.current() == Some(WHITESPACE)
310                || self.current() == Some(CONTINUATION)
311                || self.current() == Some(COMMENT)
312                || self.current() == Some(NEWLINE)
313            {
314                self.bump();
315            }
316            if let Some(_v) = self.parse_version() {
317                // Version is stored in the syntax tree, no need to track separately
318            }
319            // TODO: use version to influence parsing
320            loop {
321                if !self.parse_watch_entry() {
322                    break;
323                }
324            }
325            // Don't forget to eat *trailing* whitespace
326            self.skip_ws();
327            // Close the root node.
328            self.builder.finish_node();
329
330            // Turn the builder into a GreenNode
331            InternalParse {
332                green_node: self.builder.finish(),
333                errors: self.errors,
334            }
335        }
336        /// Advance one token, adding it to the current branch of the tree builder.
337        fn bump(&mut self) {
338            if let Some((kind, text)) = self.tokens.pop() {
339                self.builder.token(kind.into(), text.as_str());
340            }
341        }
342        /// Peek at the first unprocessed token
343        fn current(&self) -> Option<SyntaxKind> {
344            self.tokens.last().map(|(kind, _)| *kind)
345        }
346        fn skip_ws(&mut self) {
347            while self.current() == Some(WHITESPACE)
348                || self.current() == Some(CONTINUATION)
349                || self.current() == Some(COMMENT)
350            {
351                self.bump()
352            }
353        }
354    }
355
356    let mut tokens = lex(text);
357    tokens.reverse();
358    Parser {
359        tokens,
360        builder: GreenNodeBuilder::new(),
361        errors: Vec::new(),
362    }
363    .parse()
364}
365
366/// To work with the parse results we need a view into the
367/// green tree - the Syntax tree.
368/// It is also immutable, like a GreenNode,
369/// but it contains parent pointers, offsets, and
370/// has identity semantics.
371type SyntaxNode = rowan::SyntaxNode<Lang>;
372#[allow(unused)]
373type SyntaxToken = rowan::SyntaxToken<Lang>;
374#[allow(unused)]
375type SyntaxElement = rowan::NodeOrToken<SyntaxNode, SyntaxToken>;
376
377impl InternalParse {
378    fn syntax(&self) -> SyntaxNode {
379        SyntaxNode::new_root(self.green_node.clone())
380    }
381
382    fn root(&self) -> WatchFile {
383        WatchFile::cast(self.syntax()).expect("root node should be a WatchFile")
384    }
385}
386
387macro_rules! ast_node {
388    ($ast:ident, $kind:ident) => {
389        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
390        #[repr(transparent)]
391        /// A node in the syntax tree for $ast
392        pub struct $ast(SyntaxNode);
393        impl $ast {
394            #[allow(unused)]
395            fn cast(node: SyntaxNode) -> Option<Self> {
396                if node.kind() == $kind {
397                    Some(Self(node))
398                } else {
399                    None
400                }
401            }
402        }
403
404        impl std::fmt::Display for $ast {
405            fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
406                write!(f, "{}", self.0.text())
407            }
408        }
409    };
410}
411
412ast_node!(WatchFile, ROOT);
413ast_node!(Version, VERSION);
414ast_node!(Entry, ENTRY);
415ast_node!(OptionList, OPTS_LIST);
416ast_node!(_Option, OPTION);
417
418impl WatchFile {
419    /// Access the underlying syntax node (needed for conversion)
420    pub(crate) fn syntax(&self) -> &SyntaxNode {
421        &self.0
422    }
423
424    /// Create a new watch file with specified version
425    pub fn new(version: Option<u32>) -> WatchFile {
426        let mut builder = GreenNodeBuilder::new();
427
428        builder.start_node(ROOT.into());
429        if let Some(version) = version {
430            builder.start_node(VERSION.into());
431            builder.token(KEY.into(), "version");
432            builder.token(EQUALS.into(), "=");
433            builder.token(VALUE.into(), version.to_string().as_str());
434            builder.token(NEWLINE.into(), "\n");
435            builder.finish_node();
436        }
437        builder.finish_node();
438        WatchFile(SyntaxNode::new_root(builder.finish()))
439    }
440
441    /// Returns the version of the watch file.
442    pub fn version(&self) -> u32 {
443        self.0
444            .children()
445            .find_map(Version::cast)
446            .map(|it| it.version())
447            .unwrap_or(DEFAULT_VERSION)
448    }
449
450    /// Returns an iterator over all entries in the watch file.
451    pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
452        self.0.children().filter_map(Entry::cast)
453    }
454}
455
456impl FromStr for WatchFile {
457    type Err = ParseError;
458
459    fn from_str(s: &str) -> Result<Self, Self::Err> {
460        let parsed = parse(s);
461        if parsed.errors.is_empty() {
462            Ok(parsed.root())
463        } else {
464            Err(ParseError(parsed.errors))
465        }
466    }
467}
468
469/// Parse a watch file and return a thread-safe parse result.
470/// This can be stored in incremental computation systems like Salsa.
471pub fn parse_watch_file(text: &str) -> Parse<WatchFile> {
472    let parsed = parse(text);
473    Parse::new(parsed.green_node, parsed.errors)
474}
475
476impl Version {
477    /// Returns the version of the watch file.
478    pub fn version(&self) -> u32 {
479        self.0
480            .children_with_tokens()
481            .find_map(|it| match it {
482                SyntaxElement::Token(token) => {
483                    if token.kind() == VALUE {
484                        token.text().parse().ok()
485                    } else {
486                        None
487                    }
488                }
489                _ => None,
490            })
491            .unwrap_or(DEFAULT_VERSION)
492    }
493}
494
495impl Entry {
496    /// Access the underlying syntax node (needed for conversion)
497    pub(crate) fn syntax(&self) -> &SyntaxNode {
498        &self.0
499    }
500
501    /// List of options
502    pub fn option_list(&self) -> Option<OptionList> {
503        self.0.children().find_map(OptionList::cast)
504    }
505
506    /// Get the value of an option
507    pub fn get_option(&self, key: &str) -> Option<String> {
508        self.option_list().and_then(|ol| ol.get_option(key))
509    }
510
511    /// Check if an option is set
512    pub fn has_option(&self, key: &str) -> bool {
513        self.option_list().is_some_and(|ol| ol.has_option(key))
514    }
515
516    /// The name of the secondary source tarball
517    pub fn component(&self) -> Option<String> {
518        self.get_option("component")
519    }
520
521    /// Component type
522    pub fn ctype(&self) -> Result<Option<ComponentType>, crate::types::ParseError> {
523        self.get_option("ctype").map(|s| s.parse()).transpose()
524    }
525
526    /// Compression method
527    pub fn compression(&self) -> Result<Option<Compression>, crate::types::ParseError> {
528        self.get_option("compression")
529            .map(|s| s.parse())
530            .transpose()
531    }
532
533    /// Repack the tarball
534    pub fn repack(&self) -> bool {
535        self.has_option("repack")
536    }
537
538    /// Repack suffix
539    pub fn repacksuffix(&self) -> Option<String> {
540        self.get_option("repacksuffix")
541    }
542
543    /// Retrieve the mode of the watch file entry.
544    pub fn mode(&self) -> Result<Mode, crate::types::ParseError> {
545        Ok(self
546            .get_option("mode")
547            .map(|s| s.parse())
548            .transpose()?
549            .unwrap_or_default())
550    }
551
552    /// Return the git pretty mode
553    pub fn pretty(&self) -> Result<Pretty, crate::types::ParseError> {
554        Ok(self
555            .get_option("pretty")
556            .map(|s| s.parse())
557            .transpose()?
558            .unwrap_or_default())
559    }
560
561    /// Set the date string used by the pretty option to an arbitrary format as an optional
562    /// opts argument when the matching-pattern is HEAD or heads/branch for git mode.
563    pub fn date(&self) -> String {
564        self.get_option("date").unwrap_or_else(|| "%Y%m%d".into())
565    }
566
567    /// Return the git export mode
568    pub fn gitexport(&self) -> Result<GitExport, crate::types::ParseError> {
569        Ok(self
570            .get_option("gitexport")
571            .map(|s| s.parse())
572            .transpose()?
573            .unwrap_or_default())
574    }
575
576    /// Return the git mode
577    pub fn gitmode(&self) -> Result<GitMode, crate::types::ParseError> {
578        Ok(self
579            .get_option("gitmode")
580            .map(|s| s.parse())
581            .transpose()?
582            .unwrap_or_default())
583    }
584
585    /// Return the pgp mode
586    pub fn pgpmode(&self) -> Result<PgpMode, crate::types::ParseError> {
587        Ok(self
588            .get_option("pgpmode")
589            .map(|s| s.parse())
590            .transpose()?
591            .unwrap_or_default())
592    }
593
594    /// Return the search mode
595    pub fn searchmode(&self) -> Result<SearchMode, crate::types::ParseError> {
596        Ok(self
597            .get_option("searchmode")
598            .map(|s| s.parse())
599            .transpose()?
600            .unwrap_or_default())
601    }
602
603    /// Return the decompression mode
604    pub fn decompress(&self) -> bool {
605        self.has_option("decompress")
606    }
607
608    /// Whether to disable all site specific special case code such as URL director uses and page
609    /// content alterations.
610    pub fn bare(&self) -> bool {
611        self.has_option("bare")
612    }
613
614    /// Set the user-agent string used to contact the HTTP(S) server as user-agent-string. (persistent)
615    pub fn user_agent(&self) -> Option<String> {
616        self.get_option("user-agent")
617    }
618
619    /// Use PASV mode for the FTP connection.
620    pub fn passive(&self) -> Option<bool> {
621        if self.has_option("passive") || self.has_option("pasv") {
622            Some(true)
623        } else if self.has_option("active") || self.has_option("nopasv") {
624            Some(false)
625        } else {
626            None
627        }
628    }
629
630    /// Add the extra options to use with the unzip command, such as -a, -aa, and -b, when executed
631    /// by mk-origtargz.
632    pub fn unzipoptions(&self) -> Option<String> {
633        self.get_option("unzipopt")
634    }
635
636    /// Normalize the downloaded web page string.
637    pub fn dversionmangle(&self) -> Option<String> {
638        self.get_option("dversionmangle")
639            .or_else(|| self.get_option("versionmangle"))
640    }
641
642    /// Normalize the directory path string matching the regex in a set of parentheses of
643    /// http://URL as the sortable version index string.  This is used
644    /// as the directory path sorting index only.
645    pub fn dirversionmangle(&self) -> Option<String> {
646        self.get_option("dirversionmangle")
647    }
648
649    /// Normalize the downloaded web page string.
650    pub fn pagemangle(&self) -> Option<String> {
651        self.get_option("pagemangle")
652    }
653
654    /// Normalize the candidate upstream version strings extracted from hrefs in the
655    /// source of the web page.  This is used as the version sorting index when selecting the
656    /// latest upstream version.
657    pub fn uversionmangle(&self) -> Option<String> {
658        self.get_option("uversionmangle")
659            .or_else(|| self.get_option("versionmangle"))
660    }
661
662    /// Syntactic shorthand for uversionmangle=rules, dversionmangle=rules
663    pub fn versionmangle(&self) -> Option<String> {
664        self.get_option("versionmangle")
665    }
666
667    /// Convert the selected upstream tarball href string from the percent-encoded hexadecimal
668    /// string to the decoded normal URL  string  for  obfuscated
669    /// web sites.  Only percent-encoding is available and it is decoded with
670    /// s/%([A-Fa-f\d]{2})/chr hex $1/eg.
671    pub fn hrefdecode(&self) -> bool {
672        self.get_option("hrefdecode").is_some()
673    }
674
675    /// Convert the selected upstream tarball href string into the accessible URL for obfuscated
676    /// web sites.  This is run after hrefdecode.
677    pub fn downloadurlmangle(&self) -> Option<String> {
678        self.get_option("downloadurlmangle")
679    }
680
681    /// Generate the upstream tarball filename from the selected href string if matching-pattern
682    /// can extract the latest upstream version <uversion> from the  selected  href  string.
683    /// Otherwise, generate the upstream tarball filename from its full URL string and set the
684    /// missing <uversion> from the generated upstream tarball filename.
685    ///
686    /// Without this option, the default upstream tarball filename is generated by taking the last
687    /// component of the URL and  removing everything  after any '?' or '#'.
688    pub fn filenamemangle(&self) -> Option<String> {
689        self.get_option("filenamemangle")
690    }
691
692    /// Generate the candidate upstream signature file URL string from the upstream tarball URL.
693    pub fn pgpsigurlmangle(&self) -> Option<String> {
694        self.get_option("pgpsigurlmangle")
695    }
696
697    /// Generate the version string <oversion> of the source tarball <spkg>_<oversion>.orig.tar.gz
698    /// from <uversion>.  This should be used to add a suffix such as +dfsg to a MUT package.
699    pub fn oversionmangle(&self) -> Option<String> {
700        self.get_option("oversionmangle")
701    }
702
703    /// Returns options set
704    pub fn opts(&self) -> std::collections::HashMap<String, String> {
705        let mut options = std::collections::HashMap::new();
706
707        if let Some(ol) = self.option_list() {
708            for opt in ol.children() {
709                let key = opt.key();
710                let value = opt.value();
711                if let (Some(key), Some(value)) = (key, value) {
712                    options.insert(key.to_string(), value.to_string());
713                }
714            }
715        }
716
717        options
718    }
719
720    fn items(&self) -> impl Iterator<Item = String> + '_ {
721        self.0.children_with_tokens().filter_map(|it| match it {
722            SyntaxElement::Token(token) => {
723                if token.kind() == VALUE || token.kind() == KEY {
724                    Some(token.text().to_string())
725                } else {
726                    None
727                }
728            }
729            _ => None,
730        })
731    }
732
733    /// Returns the URL of the entry.
734    pub fn url(&self) -> String {
735        self.items().next().unwrap_or_default()
736    }
737
738    /// Returns the matching pattern of the entry.
739    pub fn matching_pattern(&self) -> Option<String> {
740        self.items().nth(1)
741    }
742
743    /// Returns the version policy
744    pub fn version(&self) -> Result<Option<crate::VersionPolicy>, crate::types::ParseError> {
745        self.items().nth(2).map(|it| it.parse()).transpose()
746    }
747
748    /// Returns the script of the entry.
749    pub fn script(&self) -> Option<String> {
750        self.items().nth(3)
751    }
752
753    /// Replace all substitutions and return the resulting URL.
754    pub fn format_url(
755        &self,
756        package: impl FnOnce() -> String,
757    ) -> Result<url::Url, url::ParseError> {
758        subst(self.url().as_str(), package).parse()
759    }
760}
761
762const SUBSTITUTIONS: &[(&str, &str)] = &[
763    // This is substituted with the source package name found in the first line
764    // of the debian/changelog file.
765    // "@PACKAGE@": None,
766    // This is substituted by the legal upstream version regex (capturing).
767    ("@ANY_VERSION@", r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"),
768    // This is substituted by the typical archive file extension regex
769    // (non-capturing).
770    (
771        "@ARCHIVE_EXT@",
772        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)",
773    ),
774    // This is substituted by the typical signature file extension regex
775    // (non-capturing).
776    (
777        "@SIGNATURE_EXT@",
778        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)",
779    ),
780    // This is substituted by the typical Debian extension regexp (capturing).
781    ("@DEB_EXT@", r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"),
782];
783
784pub fn subst(text: &str, package: impl FnOnce() -> String) -> String {
785    // Early return if no substitutions are needed
786    if !text.contains('@') {
787        return text.to_string();
788    }
789
790    // Apply all substitutions in a single pass using fold
791    let result = SUBSTITUTIONS
792        .iter()
793        .fold(text.to_string(), |acc, (pattern, replacement)| {
794            acc.replace(pattern, replacement)
795        });
796
797    // Handle @PACKAGE@ substitution if needed
798    if result.contains("@PACKAGE@") {
799        let package_name = package();
800        result.replace("@PACKAGE@", &package_name)
801    } else {
802        result
803    }
804}
805
806#[test]
807fn test_subst() {
808    assert_eq!(
809        subst("@ANY_VERSION@", || unreachable!()),
810        r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"
811    );
812    assert_eq!(subst("@PACKAGE@", || "dulwich".to_string()), "dulwich");
813}
814
815impl OptionList {
816    fn children(&self) -> impl Iterator<Item = _Option> + '_ {
817        self.0.children().filter_map(_Option::cast)
818    }
819
820    pub fn has_option(&self, key: &str) -> bool {
821        self.children().any(|it| it.key().as_deref() == Some(key))
822    }
823
824    pub fn get_option(&self, key: &str) -> Option<String> {
825        self.children().find_map(|child| {
826            if child.key().as_deref() == Some(key) {
827                child.value()
828            } else {
829                None
830            }
831        })
832    }
833
834    /// Returns an iterator over all options as (key, value) pairs
835    pub(crate) fn options(&self) -> impl Iterator<Item = (String, String)> + '_ {
836        self.children().filter_map(|opt| {
837            if let (Some(key), Some(value)) = (opt.key(), opt.value()) {
838                Some((key, value))
839            } else {
840                None
841            }
842        })
843    }
844}
845
846impl _Option {
847    /// Returns the key of the option.
848    pub fn key(&self) -> Option<String> {
849        self.0.children_with_tokens().find_map(|it| match it {
850            SyntaxElement::Token(token) => {
851                if token.kind() == KEY {
852                    Some(token.text().to_string())
853                } else {
854                    None
855                }
856            }
857            _ => None,
858        })
859    }
860
861    /// Returns the value of the option.
862    pub fn value(&self) -> Option<String> {
863        self.0
864            .children_with_tokens()
865            .filter_map(|it| match it {
866                SyntaxElement::Token(token) => {
867                    if token.kind() == VALUE || token.kind() == KEY {
868                        Some(token.text().to_string())
869                    } else {
870                        None
871                    }
872                }
873                _ => None,
874            })
875            .nth(1)
876    }
877}
878
879#[test]
880fn test_parse_v1() {
881    const WATCHV1: &str = r#"version=4
882opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
883  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
884"#;
885    let parsed = parse(WATCHV1);
886    //assert_eq!(parsed.errors, Vec::<String>::new());
887    let node = parsed.syntax();
888    assert_eq!(
889        format!("{:#?}", node),
890        r#"ROOT@0..161
891  VERSION@0..10
892    KEY@0..7 "version"
893    EQUALS@7..8 "="
894    VALUE@8..9 "4"
895    NEWLINE@9..10 "\n"
896  ENTRY@10..161
897    OPTS_LIST@10..86
898      KEY@10..14 "opts"
899      EQUALS@14..15 "="
900      OPTION@15..19
901        KEY@15..19 "bare"
902      COMMA@19..20 ","
903      OPTION@20..86
904        KEY@20..34 "filenamemangle"
905        EQUALS@34..35 "="
906        VALUE@35..86 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..."
907    WHITESPACE@86..87 " "
908    CONTINUATION@87..89 "\\\n"
909    WHITESPACE@89..91 "  "
910    VALUE@91..138 "https://github.com/sy ..."
911    WHITESPACE@138..139 " "
912    VALUE@139..160 ".*/v?(\\d\\S+)\\.tar\\.gz"
913    NEWLINE@160..161 "\n"
914"#
915    );
916
917    let root = parsed.root();
918    assert_eq!(root.version(), 4);
919    let entries = root.entries().collect::<Vec<_>>();
920    assert_eq!(entries.len(), 1);
921    let entry = &entries[0];
922    assert_eq!(
923        entry.url(),
924        "https://github.com/syncthing/syncthing-gtk/tags"
925    );
926    assert_eq!(
927        entry.matching_pattern(),
928        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
929    );
930    assert_eq!(entry.version(), Ok(None));
931    assert_eq!(entry.script(), None);
932
933    assert_eq!(node.text(), WATCHV1);
934}
935
936#[test]
937fn test_parse_v2() {
938    let parsed = parse(
939        r#"version=4
940https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
941# comment
942"#,
943    );
944    assert_eq!(parsed.errors, Vec::<String>::new());
945    let node = parsed.syntax();
946    assert_eq!(
947        format!("{:#?}", node),
948        r###"ROOT@0..90
949  VERSION@0..10
950    KEY@0..7 "version"
951    EQUALS@7..8 "="
952    VALUE@8..9 "4"
953    NEWLINE@9..10 "\n"
954  ENTRY@10..80
955    VALUE@10..57 "https://github.com/sy ..."
956    WHITESPACE@57..58 " "
957    VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz"
958    NEWLINE@79..80 "\n"
959  COMMENT@80..89 "# comment"
960  NEWLINE@89..90 "\n"
961"###
962    );
963
964    let root = parsed.root();
965    assert_eq!(root.version(), 4);
966    let entries = root.entries().collect::<Vec<_>>();
967    assert_eq!(entries.len(), 1);
968    let entry = &entries[0];
969    assert_eq!(
970        entry.url(),
971        "https://github.com/syncthing/syncthing-gtk/tags"
972    );
973    assert_eq!(
974        entry.format_url(|| "syncthing-gtk".to_string()).unwrap(),
975        "https://github.com/syncthing/syncthing-gtk/tags"
976            .parse()
977            .unwrap()
978    );
979}
980
981#[test]
982fn test_parse_v3() {
983    let parsed = parse(
984        r#"version=4
985https://github.com/syncthing/@PACKAGE@/tags .*/v?(\d\S+)\.tar\.gz
986# comment
987"#,
988    );
989    assert_eq!(parsed.errors, Vec::<String>::new());
990    let root = parsed.root();
991    assert_eq!(root.version(), 4);
992    let entries = root.entries().collect::<Vec<_>>();
993    assert_eq!(entries.len(), 1);
994    let entry = &entries[0];
995    assert_eq!(entry.url(), "https://github.com/syncthing/@PACKAGE@/tags");
996    assert_eq!(
997        entry.format_url(|| "syncthing-gtk".to_string()).unwrap(),
998        "https://github.com/syncthing/syncthing-gtk/tags"
999            .parse()
1000            .unwrap()
1001    );
1002}
1003
1004#[test]
1005fn test_thread_safe_parsing() {
1006    let text = r#"version=4
1007https://github.com/example/example/tags example-(.*)\.tar\.gz
1008"#;
1009
1010    let parsed = parse_watch_file(text);
1011    assert!(parsed.is_ok());
1012    assert_eq!(parsed.errors().len(), 0);
1013
1014    // Test that we can get the AST from the parse result
1015    let watchfile = parsed.tree();
1016    assert_eq!(watchfile.version(), 4);
1017    let entries: Vec<_> = watchfile.entries().collect();
1018    assert_eq!(entries.len(), 1);
1019}
1020
1021#[test]
1022fn test_parse_clone_and_eq() {
1023    let text = r#"version=4
1024https://github.com/example/example/tags example-(.*)\.tar\.gz
1025"#;
1026
1027    let parsed1 = parse_watch_file(text);
1028    let parsed2 = parsed1.clone();
1029
1030    // Test that cloned parse results are equal
1031    assert_eq!(parsed1, parsed2);
1032
1033    // Test that the AST nodes are also cloneable
1034    let watchfile1 = parsed1.tree();
1035    let watchfile2 = watchfile1.clone();
1036    assert_eq!(watchfile1, watchfile2);
1037}
1038
1039#[test]
1040fn test_parse_v4() {
1041    let cl: super::WatchFile = r#"version=4
1042opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1043    https://github.com/example/example-cat/tags \
1044        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1045"#
1046    .parse()
1047    .unwrap();
1048    assert_eq!(cl.version(), 4);
1049    let entries = cl.entries().collect::<Vec<_>>();
1050    assert_eq!(entries.len(), 1);
1051    let entry = &entries[0];
1052    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1053    assert_eq!(
1054        entry.matching_pattern(),
1055        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1056    );
1057    assert!(entry.repack());
1058    assert_eq!(entry.compression(), Ok(Some(Compression::Xz)));
1059    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
1060    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
1061    assert_eq!(entry.script(), Some("uupdate".into()));
1062    assert_eq!(
1063        entry.format_url(|| "example-cat".to_string()).unwrap(),
1064        "https://github.com/example/example-cat/tags"
1065            .parse()
1066            .unwrap()
1067    );
1068    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1069}
1070
1071#[test]
1072fn test_git_mode() {
1073    let text = r#"version=3
1074opts="mode=git, gitmode=shallow, pgpmode=gittag" \
1075https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git \
1076refs/tags/(.*) debian
1077"#;
1078    let parsed = parse(text);
1079    assert_eq!(parsed.errors, Vec::<String>::new());
1080    let cl = parsed.root();
1081    assert_eq!(cl.version(), 3);
1082    let entries = cl.entries().collect::<Vec<_>>();
1083    assert_eq!(entries.len(), 1);
1084    let entry = &entries[0];
1085    assert_eq!(
1086        entry.url(),
1087        "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git"
1088    );
1089    assert_eq!(entry.matching_pattern(), Some("refs/tags/(.*)".into()));
1090    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1091    assert_eq!(entry.script(), None);
1092    assert_eq!(entry.gitmode(), Ok(GitMode::Shallow));
1093    assert_eq!(entry.pgpmode(), Ok(PgpMode::GitTag));
1094    assert_eq!(entry.mode(), Ok(Mode::Git));
1095}
1096
1097#[test]
1098fn test_parse_quoted() {
1099    const WATCHV1: &str = r#"version=4
1100opts="bare, filenamemangle=blah" \
1101  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1102"#;
1103    let parsed = parse(WATCHV1);
1104    //assert_eq!(parsed.errors, Vec::<String>::new());
1105    let node = parsed.syntax();
1106
1107    let root = parsed.root();
1108    assert_eq!(root.version(), 4);
1109    let entries = root.entries().collect::<Vec<_>>();
1110    assert_eq!(entries.len(), 1);
1111    let entry = &entries[0];
1112
1113    assert_eq!(
1114        entry.url(),
1115        "https://github.com/syncthing/syncthing-gtk/tags"
1116    );
1117    assert_eq!(
1118        entry.matching_pattern(),
1119        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1120    );
1121    assert_eq!(entry.version(), Ok(None));
1122    assert_eq!(entry.script(), None);
1123
1124    assert_eq!(node.text(), WATCHV1);
1125}
1126
1127// Trait implementations for formats 1-4
1128
1129impl crate::traits::WatchFileFormat for WatchFile {
1130    type Entry = Entry;
1131
1132    fn version(&self) -> u32 {
1133        self.version()
1134    }
1135
1136    fn entries(&self) -> Box<dyn Iterator<Item = Self::Entry> + '_> {
1137        Box::new(WatchFile::entries(self))
1138    }
1139
1140    fn to_string(&self) -> String {
1141        ToString::to_string(self)
1142    }
1143}
1144
1145impl crate::traits::WatchEntry for Entry {
1146    fn url(&self) -> String {
1147        Entry::url(self)
1148    }
1149
1150    fn matching_pattern(&self) -> Option<String> {
1151        Entry::matching_pattern(self)
1152    }
1153
1154    fn version_policy(&self) -> Result<Option<crate::VersionPolicy>, crate::types::ParseError> {
1155        Entry::version(self)
1156    }
1157
1158    fn script(&self) -> Option<String> {
1159        Entry::script(self)
1160    }
1161
1162    fn get_option(&self, key: &str) -> Option<String> {
1163        Entry::get_option(self, key)
1164    }
1165
1166    fn has_option(&self, key: &str) -> bool {
1167        Entry::has_option(self, key)
1168    }
1169}