debian-watch-0.2.1/.cargo_vcs_info.json0000644000000001360000000000100133620ustar { "git": { "sha1": "240f6d3903b30243f38c13fb081ab873c77bb912" }, "path_in_vcs": "" }debian-watch-0.2.1/.github/workflows/rust.yml000064400000000000000000000004111046102023000172630ustar 00000000000000name: Rust on: push: pull_request: env: CARGO_TERM_COLOR: always jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build run: cargo build --verbose - name: Run tests run: cargo test --verbose debian-watch-0.2.1/.gitignore000064400000000000000000000000131046102023000141340ustar 00000000000000target .*~ debian-watch-0.2.1/Cargo.toml0000644000000016220000000000100113610ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "debian-watch" version = "0.2.1" authors = ["Jelmer Vernooij "] description = "parser for Debian watch files" homepage = "https://github.com/jelmer/debian-watch-rs" readme = "README.md" license = "Apache-2.0" repository = "https://github.com/jelmer/debian-watch-rs.git" [dependencies.m_lexer] version = "0.0.4" [dependencies.maplit] version = "1.0.2" [dependencies.rowan] version = "0.15.11" debian-watch-0.2.1/Cargo.toml.orig000064400000000000000000000005621046102023000150440ustar 00000000000000[package] name = "debian-watch" version = "0.2.1" authors = ["Jelmer Vernooij "] edition = "2021" license = "Apache-2.0" description = "parser for Debian watch files" repository = "https://github.com/jelmer/debian-watch-rs.git" homepage = "https://github.com/jelmer/debian-watch-rs" [dependencies] rowan = "0.15.11" m_lexer = "0.0.4" maplit = "1.0.2" debian-watch-0.2.1/README.md000064400000000000000000000021061046102023000134300ustar 00000000000000Format-preserving parser and editor for Debian watch files ========================================================== This crate supports reading, editing and writing Debian watch files, while preserving the original contents byte-for-byte. Example: ```rust let wf = debian_watch::WatchFile::new(None); assert_eq!(wf.version(), debian_watch::DEFAULT_VERSION); assert_eq!("", wf.to_string()); let wf = debian_watch::WatchFile::new(Some(4)); assert_eq!(wf.version(), 4); assert_eq!("version=4\n", wf.to_string()); let wf: debian_watch::WatchFile = r#"version=4 opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz "#.parse().unwrap(); assert_eq!(wf.version(), 4); assert_eq!(wf.entries().collect::>().len(), 1); let entry = wf.entries().next().unwrap(); assert_eq!(entry.opts(), maplit::hashmap! { "foo".to_string() => "blah".to_string(), }); assert_eq!(&entry.url(), "https://foo.com/bar"); assert_eq!(entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz")); ``` It also supports partial parsing (with some error nodes), which could be useful for e.g. IDEs. debian-watch-0.2.1/src/lex.rs000064400000000000000000000052761046102023000141110ustar 00000000000000use crate::SyntaxKind; use crate::SyntaxKind::*; /// Split the input string into a flat list of tokens pub(crate) fn lex(text: &str) -> Vec<(SyntaxKind, String)> { fn tok(t: SyntaxKind) -> m_lexer::TokenKind { let sk = rowan::SyntaxKind::from(t); m_lexer::TokenKind(sk.0) } fn kind(t: m_lexer::TokenKind) -> SyntaxKind { match t.0 { 0 => KEY, 1 => VALUE, 2 => EQUALS, 3 => COMMA, 4 => CONTINUATION, 5 => NEWLINE, 6 => WHITESPACE, 7 => COMMENT, 8 => ERROR, _ => unreachable!(), } } let lexer = m_lexer::LexerBuilder::new() .error_token(tok(ERROR)) .tokens(&[ (tok(KEY), r"[a-z]+"), (tok(VALUE), r"[^\s=,]*[^\s=\\,]"), (tok(CONTINUATION), r"\\\n"), (tok(EQUALS), r"="), (tok(COMMA), r","), (tok(NEWLINE), r"\n"), (tok(WHITESPACE), r"\s+"), (tok(COMMENT), r"#[^\n]*"), ]) .build(); lexer .tokenize(text) .into_iter() .map(|t| (t.len, kind(t.kind))) .scan(0usize, |start_offset, (len, kind)| { let s: String = text[*start_offset..*start_offset + len].into(); *start_offset += len; Some((kind, s)) }) .collect() } #[cfg(test)] mod tests { use crate::SyntaxKind::*; #[test] fn test_empty() { assert_eq!(super::lex(""), vec![]); } #[test] fn test_simple() { assert_eq!( super::lex( r#"version=4 opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "# ), vec![ (KEY, "version".into()), (EQUALS, "=".into()), (VALUE, "4".into()), (NEWLINE, "\n".into()), (KEY, "opts".into()), (EQUALS, "=".into()), (KEY, "filenamemangle".into()), (EQUALS, "=".into()), ( VALUE, "s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into() ), (WHITESPACE, " ".into()), (CONTINUATION, "\\\n".into()), (WHITESPACE, " ".into()), ( VALUE, "https://github.com/syncthing/syncthing-gtk/tags".into() ), (WHITESPACE, " ".into()), (VALUE, ".*/v?(\\d\\S+)\\.tar\\.gz".into()), (NEWLINE, "\n".into()), ] ); } } debian-watch-0.2.1/src/lib.rs000064400000000000000000000043541046102023000140630ustar 00000000000000//! Formatting-preserving parser and editor for Debian watch files //! //! # Example //! //! ```rust //! let wf = debian_watch::WatchFile::new(None); //! assert_eq!(wf.version(), debian_watch::DEFAULT_VERSION); //! assert_eq!("", wf.to_string()); //! //! let wf = debian_watch::WatchFile::new(Some(4)); //! assert_eq!(wf.version(), 4); //! assert_eq!("version=4\n", wf.to_string()); //! //! let wf: debian_watch::WatchFile = r#"version=4 //! opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz //! "#.parse().unwrap(); //! assert_eq!(wf.version(), 4); //! assert_eq!(wf.entries().collect::>().len(), 1); //! let entry = wf.entries().next().unwrap(); //! assert_eq!(entry.opts(), maplit::hashmap! { //! "foo".to_string() => "blah".to_string(), //! }); //! assert_eq!(&entry.url(), "https://foo.com/bar"); //! assert_eq!(entry.matching_pattern().as_deref(), Some(".*/v?(\\d\\S+)\\.tar\\.gz")); //! ``` mod lex; mod parse; /// Any watch files without a version are assumed to be /// version 1. pub const DEFAULT_VERSION: u32 = 1; /// Let's start with defining all kinds of tokens and /// composite nodes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[allow(non_camel_case_types)] #[repr(u16)] pub(crate) enum SyntaxKind { KEY = 0, VALUE, EQUALS, COMMA, CONTINUATION, NEWLINE, WHITESPACE, // whitespaces is explicit COMMENT, // comments ERROR, // as well as errors // composite nodes ROOT, // The entire file VERSION, // "version=x\n" ENTRY, // "opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz\n" OPTS_LIST, // "opts=foo=blah" OPTION, // "foo=blah" } /// Convert our `SyntaxKind` into the rowan `SyntaxKind`. impl From for rowan::SyntaxKind { fn from(kind: SyntaxKind) -> Self { Self(kind as u16) } } pub use crate::parse::Entry; pub use crate::parse::WatchFile; #[cfg(test)] mod tests { #[test] fn test_create_watchfile() { let wf = super::WatchFile::new(None); assert_eq!(wf.version(), super::DEFAULT_VERSION); assert_eq!("", wf.to_string()); let wf = super::WatchFile::new(Some(4)); assert_eq!(wf.version(), 4); assert_eq!("version=4\n", wf.to_string()); } } debian-watch-0.2.1/src/parse.rs000064400000000000000000000423411046102023000144250ustar 00000000000000use crate::lex::lex; use crate::SyntaxKind; use crate::SyntaxKind::*; use crate::DEFAULT_VERSION; use std::str::FromStr; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ParseError(Vec); impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { for err in &self.0 { writeln!(f, "{}", err)?; } Ok(()) } } impl std::error::Error for ParseError {} /// Second, implementing the `Language` trait teaches rowan to convert between /// these two SyntaxKind types, allowing for a nicer SyntaxNode API where /// "kinds" are values from our `enum SyntaxKind`, instead of plain u16 values. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] enum Lang {} impl rowan::Language for Lang { type Kind = SyntaxKind; fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind { unsafe { std::mem::transmute::(raw.0) } } fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind { kind.into() } } /// GreenNode is an immutable tree, which is cheap to change, /// but doesn't contain offsets and parent pointers. use rowan::GreenNode; /// You can construct GreenNodes by hand, but a builder /// is helpful for top-down parsers: it maintains a stack /// of currently in-progress nodes use rowan::GreenNodeBuilder; /// The parse results are stored as a "green tree". /// We'll discuss working with the results later struct Parse { green_node: GreenNode, #[allow(unused)] errors: Vec, } fn parse(text: &str) -> Parse { struct Parser { /// input tokens, including whitespace, /// in *reverse* order. tokens: Vec<(SyntaxKind, String)>, /// the in-progress tree. builder: GreenNodeBuilder<'static>, /// the list of syntax errors we've accumulated /// so far. errors: Vec, } impl Parser { fn parse_version(&mut self) -> Option { let mut version = None; if self.tokens.last() == Some(&(KEY, "version".to_string())) { self.builder.start_node(VERSION.into()); self.bump(); self.skip_ws(); if self.current() != Some(EQUALS) { self.builder.start_node(ERROR.into()); self.errors.push("expected `=`".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } if self.current() != Some(VALUE) { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected value, got {:?}", self.current())); self.bump(); self.builder.finish_node(); } else { let version_str = self.tokens.last().unwrap().1.clone(); match version_str.parse() { Ok(v) => { version = Some(v); self.bump(); } Err(_) => { self.builder.start_node(ERROR.into()); self.errors .push(format!("invalid version: {}", version_str)); self.bump(); self.builder.finish_node(); } } } if self.current() != Some(NEWLINE) { self.builder.start_node(ERROR.into()); self.errors.push("expected newline".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } self.builder.finish_node(); } version } fn parse_watch_entry(&mut self) -> bool { self.skip_ws(); if self.current().is_none() { return false; } if self.current() == Some(NEWLINE) { self.bump(); return false; } self.builder.start_node(ENTRY.into()); self.parse_options_list(); for i in 1..3 { if self.current() == Some(NEWLINE) { break; } if self.current() == Some(CONTINUATION) { self.bump(); self.skip_ws(); continue; } if self.current() != Some(VALUE) && self.current() != Some(KEY) { self.builder.start_node(ERROR.into()); self.errors.push(format!( "expected value, got {:?} (i={})", self.current(), i )); self.bump(); self.builder.finish_node(); } else { self.bump(); } self.skip_ws(); } if self.current() != Some(NEWLINE) { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected newline, not {:?}", self.current())); self.bump(); self.builder.finish_node(); } else { self.bump(); } self.builder.finish_node(); true } fn parse_option(&mut self) -> bool { if self.current().is_none() { return false; } if self.current() == Some(WHITESPACE) { return false; } self.builder.start_node(OPTION.into()); if self.current() != Some(KEY) { self.builder.start_node(ERROR.into()); self.errors.push("expected key".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } if self.current() != Some(EQUALS) { self.builder.start_node(ERROR.into()); self.errors.push("expected `=`".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } if self.current() != Some(VALUE) && self.current() != Some(KEY) { self.builder.start_node(ERROR.into()); self.errors .push(format!("expected value, got {:?}", self.current())); self.bump(); self.builder.finish_node(); } else { self.bump(); } self.builder.finish_node(); true } fn parse_options_list(&mut self) { self.skip_ws(); if self.tokens.last() == Some(&(KEY, "opts".to_string())) || self.tokens.last() == Some(&(KEY, "options".to_string())) { self.builder.start_node(OPTS_LIST.into()); self.bump(); self.skip_ws(); if self.current() != Some(EQUALS) { self.builder.start_node(ERROR.into()); self.errors.push("expected `=`".to_string()); self.bump(); self.builder.finish_node(); } else { self.bump(); } loop { if !self.parse_option() { break; } } self.builder.finish_node(); self.skip_ws(); } } fn parse(mut self) -> Parse { let mut version = 1; // Make sure that the root node covers all source self.builder.start_node(ROOT.into()); if let Some(v) = self.parse_version() { version = v; } loop { if !self.parse_watch_entry() { break; } } // Don't forget to eat *trailing* whitespace self.skip_ws(); // Close the root node. self.builder.finish_node(); // Turn the builder into a GreenNode Parse { green_node: self.builder.finish(), errors: self.errors, } } /// Advance one token, adding it to the current branch of the tree builder. fn bump(&mut self) { let (kind, text) = self.tokens.pop().unwrap(); self.builder.token(kind.into(), text.as_str()); } /// Peek at the first unprocessed token fn current(&self) -> Option { self.tokens.last().map(|(kind, _)| *kind) } fn skip_ws(&mut self) { while self.current() == Some(WHITESPACE) || self.current() == Some(CONTINUATION) || self.current() == Some(COMMENT) { self.bump() } } } let mut tokens = lex(text); tokens.reverse(); Parser { tokens, builder: GreenNodeBuilder::new(), errors: Vec::new(), } .parse() } /// To work with the parse results we need a view into the /// green tree - the Syntax tree. /// It is also immutable, like a GreenNode, /// but it contains parent pointers, offsets, and /// has identity semantics. type SyntaxNode = rowan::SyntaxNode; #[allow(unused)] type SyntaxToken = rowan::SyntaxToken; #[allow(unused)] type SyntaxElement = rowan::NodeOrToken; impl Parse { fn syntax(&self) -> SyntaxNode { SyntaxNode::new_root(self.green_node.clone()) } fn root(&self) -> WatchFile { WatchFile::cast(self.syntax()).unwrap() } } macro_rules! ast_node { ($ast:ident, $kind:ident) => { #[derive(PartialEq, Eq, Hash)] #[repr(transparent)] pub struct $ast(SyntaxNode); impl $ast { #[allow(unused)] fn cast(node: SyntaxNode) -> Option { if node.kind() == $kind { Some(Self(node)) } else { None } } } impl ToString for $ast { fn to_string(&self) -> String { self.0.text().to_string() } } }; } ast_node!(WatchFile, ROOT); ast_node!(Version, VERSION); ast_node!(Entry, ENTRY); ast_node!(OptionList, OPTS_LIST); ast_node!(_Option, OPTION); impl WatchFile { pub fn new(version: Option) -> WatchFile { let mut builder = GreenNodeBuilder::new(); builder.start_node(ROOT.into()); if let Some(version) = version { builder.start_node(VERSION.into()); builder.token(KEY.into(), "version"); builder.token(EQUALS.into(), "="); builder.token(VALUE.into(), version.to_string().as_str()); builder.token(NEWLINE.into(), "\n"); builder.finish_node(); } builder.finish_node(); WatchFile(SyntaxNode::new_root(builder.finish())) } /// Returns the version of the watch file. pub fn version(&self) -> u32 { self.0 .children() .find_map(Version::cast) .map(|it| it.version()) .unwrap_or(DEFAULT_VERSION) } /// Returns an iterator over all entries in the watch file. pub fn entries(&self) -> impl Iterator + '_ { self.0.children().filter_map(Entry::cast) } } impl FromStr for WatchFile { type Err = ParseError; fn from_str(s: &str) -> Result { let parsed = parse(s); if parsed.errors.is_empty() { Ok(parsed.root()) } else { Err(ParseError(parsed.errors)) } } } impl Version { /// Returns the version of the watch file. pub fn version(&self) -> u32 { self.0 .children_with_tokens() .find_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE { Some(token.text().parse().unwrap()) } else { None } } _ => None, }) .unwrap_or(DEFAULT_VERSION) } } impl Entry { pub fn option_list(&self) -> Option { self.0.children().find_map(OptionList::cast) } /// Returns options set pub fn opts(&self) -> std::collections::HashMap { let mut options = std::collections::HashMap::new(); if let Some(ol) = self.option_list() { for opt in ol.children() { let key = opt.key(); let value = opt.value(); if let (Some(key), Some(value)) = (key, value) { options.insert(key.to_string(), value.to_string()); } } } options } fn items(&self) -> impl Iterator + '_ { self.0.children_with_tokens().filter_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE || token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) } /// Returns the URL of the entry. pub fn url(&self) -> String { self.items().next().unwrap() } /// Returns the matching pattern of the entry. pub fn matching_pattern(&self) -> Option { self.items().nth(1) } pub fn version(&self) -> Option { self.items().nth(2) } pub fn script(&self) -> Option { self.items().nth(3) } } impl OptionList { fn children(&self) -> impl Iterator + '_ { self.0.children().filter_map(_Option::cast) } } impl _Option { /// Returns the key of the option. pub fn key(&self) -> Option { self.0.children_with_tokens().find_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) } /// Returns the value of the option. pub fn value(&self) -> Option { self.0 .children_with_tokens() .filter_map(|it| match it { SyntaxElement::Token(token) => { if token.kind() == VALUE || token.kind() == KEY { Some(token.text().to_string()) } else { None } } _ => None, }) .nth(1) } } #[test] fn test_parse_v1() { const WATCHV1: &str = r#"version=4 opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \ https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz "#; let parsed = parse(WATCHV1); assert_eq!(parsed.errors, Vec::::new()); let node = parsed.syntax(); assert_eq!( format!("{:#?}", node), r#"ROOT@0..156 VERSION@0..10 KEY@0..7 "version" EQUALS@7..8 "=" VALUE@8..9 "4" NEWLINE@9..10 "\n" ENTRY@10..156 OPTS_LIST@10..81 KEY@10..14 "opts" EQUALS@14..15 "=" OPTION@15..81 KEY@15..29 "filenamemangle" EQUALS@29..30 "=" VALUE@30..81 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..." WHITESPACE@81..82 " " CONTINUATION@82..84 "\\\n" WHITESPACE@84..86 " " VALUE@86..133 "https://github.com/sy ..." WHITESPACE@133..134 " " VALUE@134..155 ".*/v?(\\d\\S+)\\.tar\\.gz" NEWLINE@155..156 "\n" "# ); let root = parsed.root(); assert_eq!(root.version(), 4); let entries = root.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); assert_eq!( entry.matching_pattern(), Some(".*/v?(\\d\\S+)\\.tar\\.gz".into()) ); assert_eq!(entry.version(), None); assert_eq!(entry.script(), None); assert_eq!(node.text(), WATCHV1); } #[test] fn test_parse_v2() { let parsed = parse( r#"version=4 https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz # comment "#, ); assert_eq!(parsed.errors, Vec::::new()); let node = parsed.syntax(); assert_eq!( format!("{:#?}", node), r###"ROOT@0..90 VERSION@0..10 KEY@0..7 "version" EQUALS@7..8 "=" VALUE@8..9 "4" NEWLINE@9..10 "\n" ENTRY@10..80 VALUE@10..57 "https://github.com/sy ..." WHITESPACE@57..58 " " VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz" NEWLINE@79..80 "\n" COMMENT@80..89 "# comment" NEWLINE@89..90 "\n" "### ); let root = parsed.root(); assert_eq!(root.version(), 4); let entries = root.entries().collect::>(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.url(), "https://github.com/syncthing/syncthing-gtk/tags" ); }