Repository: jugglerchris/rust-html2text
Pull request #151 (2024-05-21 05:03:53)

Replace the CSS parser
Replace the third party CSS parser (which is large, and has a lot of dependencies) with a custom one implementing only what's needed for the application.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e82b17b..2dc05cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,31 @@ Possible log types:
 - `[fixed]` for any bug fixes.
 - `[security]` to invite users to upgrade in case of vulnerabilities.
 
+### 0.13.0-alpha.0
+
+- [changed] Replaced LightningCSS with a smaller CSS parser.  There is a chance
+  that some CSS edge cases which no longer work; if so this would be a bug.
+- [removed] Some previously `pub` items and methods which are either internal
+  implementation details or considered redundant have been removed or made
+  private (thanks sftse).  Please open an issue for anything removed that was
+  being used.
+
+  Of note, `RenderTree::render_plain()` and `RenderTree::render_rich()` have
+  been removed.  Replace code like:
+
+  ```rust
+  let text = html2text::parse(html)?
+      .render_plain(80)?
+      .into_string()?;
+  ```
+  with:
+  ```rust
+  let text = html2text::config::plain()
+      .render_to_string(html2text::parse(html)?)?
+  ```
+
+- [changed] Updated some dependencies
+
 ### 0.12.5
 
 - [changed] Updated some dependencies
diff --git a/Cargo.toml b/Cargo.toml
index 7991d05..540282c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "html2text"
-version = "0.12.5"
+version = "0.13.0-alpha.0"
 authors = ["Chris Emerson <github@mail.nosreme.org>"]
 description = "Render HTML as plain text."
 repository = "https://github.com/jugglerchris/rust-html2text/"
@@ -20,16 +20,14 @@ tendril = "0.4"
 unicode-width = "0.1.5"
 backtrace = { version = "0.3", optional=true }
 thiserror = "1.0.50"
-lightningcss = { version = "1.0.0-alpha.54", optional=true }
-# Keep dashmap back; 5.5 has a higher MSRV.
-dashmap = { version = "~5.4", optional=true }
 log = { version = "0.4.20", optional = true }
+nom = "7.1.3"
 
 [features]
 html_trace = ["dep:log"]
 html_trace_bt = ["html_trace", "dep:backtrace"]
 default = []
-css = ["dep:lightningcss"]
+css = []
 
 [[example]]
 name = "html2term"
diff --git a/src/css.rs b/src/css.rs
index cf8d36c..e8aeff2 100644
--- a/src/css.rs
+++ b/src/css.rs
@@ -1,40 +1,30 @@
 //! Some basic CSS support.
-use std::convert::TryFrom;
 use std::io::Write;
 use std::ops::Deref;
 
-use lightningcss::{
-    declaration::DeclarationBlock,
-    properties::{
-        display::{self, DisplayKeyword},
-        overflow::{Overflow, OverflowKeyword},
-        Property,
-    },
-    rules::CssRule,
-    stylesheet::{ParserOptions, StyleAttribute, StyleSheet},
-    traits::Parse,
-    values::color::CssColor,
-};
+mod parser;
 
 use crate::{
+    css::parser::parse_rules,
     markup5ever_rcdom::{
         Handle,
         NodeData::{self, Comment, Document, Element},
     },
-    tree_map_reduce, Result, TreeMapResult,
+    tree_map_reduce, Colour, Result, TreeMapResult,
 };
 
-#[derive(Debug, Clone)]
-enum SelectorComponent {
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) enum SelectorComponent {
     Class(String),
     Element(String),
+    Hash(String),
     Star,
     CombChild,
     CombDescendant,
 }
 
-#[derive(Debug, Clone)]
-struct Selector {
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct Selector {
     // List of components, right first so we match from the leaf.
     components: Vec<SelectorComponent>,
 }
@@ -42,16 +32,14 @@ struct Selector {
 impl Selector {
     fn do_matches(comps: &[SelectorComponent], node: &Handle) -> bool {
         match comps.first() {
-            None => return true,
+            None => true,
             Some(comp) => match comp {
                 SelectorComponent::Class(class) => match &node.data {
                     Document
                     | NodeData::Doctype { .. }
                     | NodeData::Text { .. }
                     | Comment { .. }
-                    | NodeData::ProcessingInstruction { .. } => {
-                        return false;
-                    }
+                    | NodeData::ProcessingInstruction { .. } => false,
                     Element { attrs, .. } => {
                         let attrs = attrs.borrow();
                         for attr in attrs.iter() {
@@ -63,35 +51,42 @@ impl Selector {
                                 }
                             }
                         }
-                        return false;
+                        false
                     }
                 },
+                SelectorComponent::Hash(hash) => {
+                    if let Element { attrs, .. } = &node.data {
+                        let attrs = attrs.borrow();
+                        for attr in attrs.iter() {
+                            if &attr.name.local == "id" && &*attr.value == hash {
+                                return Self::do_matches(&comps[1..], node);
+                            }
+                        }
+                    }
+                    false
+                }
                 SelectorComponent::Element(name) => match &node.data {
                     Element { name: eltname, .. } => {
                         if name == eltname.expanded().local.deref() {
-                            return Self::do_matches(&comps[1..], node);
+                            Self::do_matches(&comps[1..], node)
                         } else {
-                            return false;
+                            false
                         }
                     }
-                    _ => {
-                        return false;
-                    }
+                    _ => false,
                 },
-                SelectorComponent::Star => {
-                    return Self::do_matches(&comps[1..], node);
-                }
+                SelectorComponent::Star => Self::do_matches(&comps[1..], node),
                 SelectorComponent::CombChild => {
                     if let Some(parent) = node.parent.take() {
                         let parent_handle = parent.upgrade();
                         node.parent.set(Some(parent));
                         if let Some(ph) = parent_handle {
-                            return Self::do_matches(&comps[1..], &ph);
+                            Self::do_matches(&comps[1..], &ph)
                         } else {
-                            return false;
+                            false
                         }
                     } else {
-                        return false;
+                        false
                     }
                 }
                 SelectorComponent::CombDescendant => {
@@ -99,13 +94,12 @@ impl Selector {
                         let parent_handle = parent.upgrade();
                         node.parent.set(Some(parent));
                         if let Some(ph) = parent_handle {
-                            return Self::do_matches(&comps[1..], &ph)
-                                || Self::do_matches(comps, &ph);
+                            Self::do_matches(&comps[1..], &ph) || Self::do_matches(comps, &ph)
                         } else {
-                            return false;
+                            false
                         }
                     } else {
-                        return false;
+                        false
                     }
                 }
             },
@@ -116,63 +110,10 @@ impl Selector {
     }
 }
 
-impl<'r, 'i> TryFrom<&'r lightningcss::selector::Selector<'i>> for Selector {
-    type Error = ();
-
-    fn try_from(
-        selector: &'r lightningcss::selector::Selector<'i>,
-    ) -> std::result::Result<Self, Self::Error> {
-        let mut components = Vec::new();
-
-        use lightningcss::selector::Combinator;
-        use lightningcss::selector::Component;
-
-        let mut si = selector.iter();
-        loop {
-            while let Some(item) = si.next() {
-                match item {
-                    Component::Class(id) => {
-                        components.push(SelectorComponent::Class(String::from(id.deref())));
-                    }
-                    Component::LocalName(name) => {
-                        components.push(SelectorComponent::Element(String::from(
-                            name.lower_name.deref(),
-                        )));
-                    }
-                    Component::ExplicitUniversalType => {
-                        components.push(SelectorComponent::Star);
-                    }
-                    _ => {
-                        html_trace!("Unknown component {:?}", item);
-                        return Err(());
-                    }
-                }
-            }
-            if let Some(comb) = si.next_sequence() {
-                match comb {
-                    Combinator::Child => {
-                        components.push(SelectorComponent::CombChild);
-                    }
-                    Combinator::Descendant => {
-                        components.push(SelectorComponent::CombDescendant);
-                    }
-                    _ => {
-                        html_trace!("Unknown combinator {:?}", comb);
-                        return Err(());
-                    }
-                }
-            } else {
-                break;
-            }
-        }
-        Ok(Selector { components })
-    }
-}
-
 #[derive(Debug, Clone)]
 pub(crate) enum Style {
-    Colour(CssColor),
-    BgColour(CssColor),
+    Colour(Colour),
+    BgColour(Colour),
     DisplayNone,
 }
 
@@ -184,101 +125,80 @@ struct Ruleset {
 
 /// Stylesheet data which can be used while building the render tree.
 #[derive(Clone, Default, Debug)]
-pub struct StyleData {
+pub(crate) struct StyleData {
     rules: Vec<Ruleset>,
 }
 
 pub(crate) fn parse_style_attribute(text: &str) -> Result<Vec<Style>> {
     html_trace_quiet!("Parsing inline style: {text}");
-    let sattr = StyleAttribute::parse(text, ParserOptions::default())
-        .map_err(|_| crate::Error::CssParseError)?;
+    let (_rest, decls) = parse_rules(text).map_err(|_| crate::Error::CssParseError)?;
 
-    let styles = styles_from_properties(&sattr.declarations);
+    let styles = styles_from_properties2(&decls);
     html_trace_quiet!("Parsed inline style: {:?}", styles);
     Ok(styles)
 }
 
-fn is_transparent(color: &CssColor) -> bool {
-    match color {
-        CssColor::CurrentColor => false,
-        CssColor::RGBA(rgba) => rgba.alpha == 0,
-        CssColor::LAB(_) => false,
-        CssColor::Predefined(_) => false,
-        CssColor::Float(_) => false,
-        CssColor::LightDark(_, _) => false,
-        CssColor::System(_) => false,
-    }
-}
-
-fn styles_from_properties(decls: &DeclarationBlock<'_>) -> Vec<Style> {
+fn styles_from_properties2(decls: &[parser::Declaration]) -> Vec<Style> {
     let mut styles = Vec::new();
-    html_trace_quiet!("styles:from_properties: {decls:?}");
+    html_trace_quiet!("styles:from_properties2: {decls:?}");
     let mut overflow_hidden = false;
     let mut height_zero = false;
-    for decl in decls
-        .declarations
-        .iter()
-        .chain(decls.important_declarations.iter())
-    {
-        html_trace_quiet!("styles:from_properties: {decl:?}");
-        match decl {
-            Property::Color(color) => {
-                if is_transparent(&color) {
-                    continue;
-                }
-                styles.push(Style::Colour(color.clone()));
+    for decl in decls {
+        html_trace_quiet!("styles:from_properties2: {decl:?}");
+        match &decl.data {
+            parser::Decl::Unknown { .. } => {}
+            parser::Decl::Color {
+                value: parser::Colour::Rgb(r, g, b),
+            } => {
+                styles.push(Style::Colour(Colour {
+                    r: *r,
+                    g: *g,
+                    b: *b,
+                }));
             }
-            Property::Background(bginfo) => {
-                let color = bginfo.last().unwrap().color.clone();
-                if is_transparent(&color) {
-                    continue;
-                }
-                styles.push(Style::BgColour(color));
-            }
-            Property::BackgroundColor(color) => {
-                if is_transparent(&color) {
-                    continue;
-                }
-                styles.push(Style::BgColour(color.clone()));
+            parser::Decl::BackgroundColor {
+                value: parser::Colour::Rgb(r, g, b),
+            } => {
+                styles.push(Style::BgColour(Colour {
+                    r: *r,
+                    g: *g,
+                    b: *b,
+                }));
             }
-            Property::Height(height) => {
-                use lightningcss::properties::size::Size::*;
-                use lightningcss::values::percentage::DimensionPercentage::*;
-                match height {
-                    LengthPercentage(Dimension(dim)) if dim.to_px() == Some(0.0) => {
+            parser::Decl::Height { value } => match value {
+                parser::Height::Auto => (),
+                parser::Height::Length(l, _) => {
+                    if *l == 0.0 {
                         height_zero = true;
                     }
-                    _ => (),
                 }
-            }
-            Property::MaxHeight(height) => {
-                use lightningcss::properties::size::MaxSize::*;
-                use lightningcss::values::percentage::DimensionPercentage::*;
-                match height {
-                    LengthPercentage(Dimension(dim)) => {
-                        // Treat max-height: 0 the same as display: none.
-                        if Some(0.0) == dim.to_px() {
-                            height_zero = true;
-                        }
+            },
+            parser::Decl::MaxHeight { value } => match value {
+                parser::Height::Auto => (),
+                parser::Height::Length(l, _) => {
+                    if *l == 0.0 {
+                        height_zero = true;
                     }
-                    _ => (),
                 }
+            },
+            parser::Decl::Overflow {
+                value: parser::Overflow::Hidden,
             }
-            Property::OverflowY(OverflowKeyword::Hidden)
-            | Property::Overflow(Overflow {
-                y: OverflowKeyword::Hidden,
-                ..
-            }) => {
+            | parser::Decl::OverflowY {
+                value: parser::Overflow::Hidden,
+            } => {
                 overflow_hidden = true;
             }
-            Property::Display(disp) => {
-                if let display::Display::Keyword(DisplayKeyword::None) = disp {
+            parser::Decl::Overflow { .. } | parser::Decl::OverflowY { .. } => {}
+            parser::Decl::Display { value } => {
+                if let parser::Display::None = value {
                     styles.push(Style::DisplayNone);
                 }
-            }
-            _ => {
-                html_trace_quiet!("CSS: Unhandled property {:?}", decl);
-            }
+            } /*
+              _ => {
+                  html_trace_quiet!("CSS: Unhandled property {:?}", decl);
+              }
+              */
         }
     }
     // If the height is set to zero and overflow hidden, treat as display: none
@@ -292,33 +212,19 @@ impl StyleData {
     /// Add some CSS source to be included.  The source will be parsed
     /// and the relevant and supported features extracted.
     pub fn add_css(&mut self, css: &str) -> Result<()> {
-        let ss = StyleSheet::parse(css, ParserOptions::default())
-            .map_err(|_| crate::Error::CssParseError)?;
+        let (_, ss) = parser::parse_stylesheet(css).map_err(|_| crate::Error::CssParseError)?;
 
-        for rule in &ss.rules.0 {
-            match rule {
-                CssRule::Style(style) => {
-                    let styles = styles_from_properties(&style.declarations);
-                    if !styles.is_empty() {
-                        for selector in &style.selectors.0 {
-                            match Selector::try_from(selector) {
-                                Ok(selector) => {
-                                    let ruleset = Ruleset {
-                                        selector,
-                                        styles: styles.clone(),
-                                    };
-                                    html_trace_quiet!("Adding ruleset {ruleset:?}");
-                                    self.rules.push(ruleset);
-                                }
-                                Err(_) => {
-                                    html_trace!("Ignoring selector {:?}", selector);
-                                    continue;
-                                }
-                            }
-                        }
-                    }
+        for rule in ss {
+            let styles = styles_from_properties2(&rule.declarations);
+            if !styles.is_empty() {
+                for selector in rule.selectors {
+                    let ruleset = Ruleset {
+                        selector,
+                        styles: styles.clone(),
+                    };
+                    html_trace_quiet!("Adding ruleset {ruleset:?}");
+                    self.rules.push(ruleset);
                 }
-                _ => (),
             }
         }
         Ok(())
@@ -346,12 +252,12 @@ impl StyleData {
                         let rules = parse_style_attribute(&attr.value).unwrap_or_default();
                         result.extend(rules);
                     } else if &*attr.name.local == "color" {
-                        if let Ok(colour) = CssColor::parse_string(&*attr.value) {
-                            result.push(Style::Colour(colour));
+                        if let Ok(colour) = parser::parse_color_attribute(&attr.value) {
+                            result.push(Style::Colour(colour.into()));
                         }
                     } else if &*attr.name.local == "bgcolor" {
-                        if let Ok(colour) = CssColor::parse_string(&*attr.value) {
-                            result.push(Style::BgColour(colour));
+                        if let Ok(colour) = parser::parse_color_attribute(&attr.value) {
+                            result.push(Style::BgColour(colour.into()));
                         }
                     }
                 }
@@ -388,9 +294,9 @@ fn combine_vecs(vecs: Vec<Vec<String>>) -> Vec<String> {
     }
 }
 
-fn extract_style_nodes<'a, 'b, T: Write>(
+fn extract_style_nodes<'a, T: Write>(
     handle: Handle,
-    _err_out: &'b mut T,
+    _err_out: &mut T,
 ) -> TreeMapResult<'a, (), Handle, Vec<String>> {
     use TreeMapResult::*;
 
@@ -423,7 +329,7 @@ fn extract_style_nodes<'a, 'b, T: Write>(
 }
 
 /// Extract stylesheet data from document.
-pub fn dom_to_stylesheet<T: Write>(handle: Handle, err_out: &mut T) -> Result<StyleData> {
+pub(crate) fn dom_to_stylesheet<T: Write>(handle: Handle, err_out: &mut T) -> Result<StyleData> {
     let styles = tree_map_reduce(&mut (), handle, |_, handle| {
         Ok(extract_style_nodes(handle, err_out))
     })?;
diff --git a/src/css/parser.rs b/src/css/parser.rs
new file mode 100644
index 0000000..ba3ca06
--- /dev/null
+++ b/src/css/parser.rs
@@ -0,0 +1,979 @@
+//! Parsing for the subset of CSS used in html2text.
+
+use std::{borrow::Cow, ops::Deref, str::FromStr};
+
+use nom::{
+    branch::alt,
+    bytes::complete::{tag, take_until},
+    character::complete::{self, digit0, digit1},
+    combinator::{fail, map, opt, recognize},
+    error::ErrorKind,
+    multi::{many0, many1, separated_list0},
+    sequence::tuple,
+    AsChar, IResult,
+};
+
+#[derive(Debug, PartialEq)]
+pub enum Colour {
+    Rgb(u8, u8, u8),
+}
+
+impl From<Colour> for crate::Colour {
+    fn from(value: Colour) -> Self {
+        match value {
+            Colour::Rgb(r, g, b) => crate::Colour { r, g, b },
+        }
+    }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum LengthUnit {
+    // Absolute units
+    In,
+    Cm,
+    Mm,
+    Pt,
+    Pc,
+    Px,
+    // Relative units
+    Em,
+    Ex,
+}
+
+#[derive(Debug, PartialEq)]
+pub enum Height {
+    #[allow(unused)]
+    Auto,
+    // If the length is 0, the unit will be Px
+    Length(f32, LengthUnit),
+}
+
+#[derive(Debug, PartialEq)]
+pub enum Overflow {
+    Visible,
+    Hidden,
+    Scroll,
+    Auto,
+}
+
+#[non_exhaustive]
+#[derive(Debug, PartialEq)]
+pub enum Display {
+    None,
+    Other,
+}
+
+#[derive(Debug, PartialEq)]
+pub enum Decl {
+    Color {
+        value: Colour,
+    },
+    BackgroundColor {
+        value: Colour,
+    },
+    Height {
+        value: Height,
+    },
+    MaxHeight {
+        value: Height,
+    },
+    Overflow {
+        value: Overflow,
+    },
+    OverflowY {
+        value: Overflow,
+    },
+    Display {
+        value: Display,
+    },
+    Unknown {
+        name: PropertyName,
+        //        value: Vec<Token>,
+    },
+}
+
+// Tokens as defined in the CSS Syntax Module Level 3
+#[allow(unused)]
+#[derive(Debug, PartialEq)]
+enum Token<'s> {
+    /// Plain identifier
+    Ident(Cow<'s, str>),
+    /// Start of a function call: <ident>(
+    Function(Cow<'s, str>),
+    /// @<ident>
+    AtKeyword(Cow<'s, str>),
+    /// #abcd12
+    Hash(Cow<'s, str>),
+    /// Quoted (double or single) string
+    String(Cow<'s, str>),
+    /// <bad-string-token>
+    BadString(Cow<'s, str>),
+    /// URL
+    Url(Cow<'s, str>),
+    /// <bad-url-token>
+    BadUrl(Cow<'s, str>),
+    /// Delim character
+    Delim(char),
+    /// Number
+    Number(Cow<'s, str>),
+    /// Dimension (number, unit)
+    Dimension(Cow<'s, str>, Cow<'s, str>),
+    /// Percentage
+    Percentage(Cow<'s, str>),
+    /// Whitespace
+    //Whitespace(Cow<'s, str>),
+    /// CDO (<!--)
+    #[allow(clippy::upper_case_acronyms)]
+    CDO,
+    /// CDC (-->)
+    #[allow(clippy::upper_case_acronyms)]
+    CDC,
+    /// Colon
+    Colon,
+    /// Semicolon
+    Semicolon,
+    /// Comma
+    Comma,
+    /// [-token
+    OpenSquare,
+    /// ]-token
+    CloseSquare,
+    /// (-token
+    OpenRound,
+    /// )-token
+    CloseRound,
+    /// {-token
+    OpenBrace,
+    /// }-token
+    CloseBrace,
+}
+
+// A raw, uninterpreted declaration value.
+#[derive(Debug, PartialEq)]
+struct RawValue<'s> {
+    tokens: Vec<Token<'s>>,
+    important: bool,
+}
+
+#[derive(Debug, PartialEq)]
+pub struct Declaration {
+    pub data: Decl,
+    pub important: Importance,
+}
+
+use super::{Selector, SelectorComponent};
+
+#[derive(Debug, PartialEq)]
+pub(crate) struct RuleSet {
+    pub selectors: Vec<Selector>,
+    pub declarations: Vec<Declaration>,
+}
+
+#[derive(Debug, PartialEq)]
+pub struct PropertyName(String);
+
+fn match_comment(text: &str) -> IResult<&str, ()> {
+    let (rest, _) = tag("/*")(text)?;
+    let (rest, _) = take_until("*/")(rest)?;
+    map(tag("*/"), |_t| ())(rest)
+}
+
+fn match_whitespace_item(text: &str) -> IResult<&str, ()> {
+    alt((map(complete::one_of(" \t\r\n\x0c"), |_c| ()), match_comment))(text)
+}
+
+fn skip_optional_whitespace(text: &str) -> IResult<&str, ()> {
+    map(many0(match_whitespace_item), |_res| ())(text)
+}
+
+fn nmstart_char(s: &str) -> IResult<&str, char> {
+    let mut iter = s.chars();
+    match iter.next() {
+        Some(c) => match c {
+            '_' | 'a'..='z' | 'A'..='Z' => Ok((iter.as_str(), c.to_ascii_lowercase())),
+            _ => IResult::Err(nom::Err::Error(nom::error::Error::new(s, ErrorKind::Fail))),
+        },
+        None => fail(s),
+    }
+}
+
+fn is_ident_start(c: char) -> bool {
+    matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '\u{0081}'..)
+}
+
+fn is_digit(c: char) -> bool {
+    c.is_ascii_digit()
+}
+
+fn nmchar_char(s: &str) -> IResult<&str, char> {
+    let mut iter = s.chars();
+    match iter.next() {
+        Some(c) => match c {
+            '_' | 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => {
+                Ok((iter.as_str(), c.to_ascii_lowercase()))
+            }
+            _ => IResult::Err(nom::Err::Error(nom::error::Error::new(s, ErrorKind::Fail))),
+        },
+        None => fail(s),
+    }
+}
+
+fn ident_escape(s: &str) -> IResult<&str, char> {
+    let (rest, _) = tag("\\")(s)?;
+    let mut chars = rest.char_indices();
+
+    match chars.next() {
+        None => {
+            // EOF: return replacement char
+            Ok((&rest, '\u{fffd}'))
+        }
+        Some((i, c)) if c.is_hex_digit() => {
+            // Option 1: up to 6 hex digits.
+            let start_idx = i;
+            let mut end_idx = i + 1;
+            for (nexti, nextc) in chars {
+                if nextc.is_hex_digit() && nexti - start_idx < 6 {
+                    continue;
+                } else {
+                    end_idx = nexti;
+                    break;
+                }
+            }
+            let val = u32::from_str_radix(&rest[start_idx..end_idx], 16).unwrap();
+            Ok((&rest[end_idx..], char::from_u32(val).unwrap_or('\u{fffd}')))
+        }
+        Some((_i, c)) => {
+            let bytes = c.len_utf8();
+            Ok((&rest[bytes..], c))
+        }
+    }
+}
+
+fn nmstart(text: &str) -> IResult<&str, char> {
+    alt((nmstart_char, ident_escape))(text)
+}
+
+fn nmchar(text: &str) -> IResult<&str, char> {
+    alt((nmchar_char, ident_escape))(text)
+}
+
+fn parse_ident(text: &str) -> IResult<&str, String> {
+    let (rest, _) = skip_optional_whitespace(text)?;
+    let mut name = Vec::new();
+    let (rest, dash) = opt(tag("-"))(rest)?;
+    if dash.is_some() {
+        name.push('-');
+    }
+
+    let (rest, start) = nmstart(rest)?;
+    name.push(start);
+
+    let (rest, chars) = many0(nmchar)(rest)?;
+    name.extend(chars);
+    Ok((rest, name.into_iter().collect()))
+}
+
+fn parse_identstring(text: &str) -> IResult<&str, String> {
+    let (rest, _) = skip_optional_whitespace(text)?;
+
+    let (rest, name) = many1(nmchar)(rest)?;
+    Ok((rest, name.into_iter().collect()))
+}
+
+fn parse_property_name(text: &str) -> IResult<&str, PropertyName> {
+    parse_ident(text).map(|(r, s)| (r, PropertyName(s)))
+}
+
+// For now ignore whitespace
+fn parse_token(text: &str) -> IResult<&str, Token> {
+    let (rest, _) = skip_optional_whitespace(text)?;
+    let mut chars = rest.chars();
+    match chars.next() {
+        None => fail(rest),
+        Some('"') | Some('\'') => parse_string_token(rest),
+        Some('#') => match parse_identstring(&rest[1..]) {
+            Ok((rest, id)) => Ok((rest, Token::Hash(id.into()))),
+            Err(_) => Ok((rest, Token::Delim('#'))),
+        },
+        Some(';') => Ok((&rest[1..], Token::Semicolon)),
+        Some('(') => Ok((&rest[1..], Token::OpenRound)),
+        Some(')') => Ok((&rest[1..], Token::CloseRound)),
+        Some('+') => match parse_numeric_token(&rest[1..]) {
+            Ok(result) => Ok(result),
+            Err(_) => Ok((&rest[1..], Token::Delim('+'))),
+        },
+        Some(',') => Ok((&rest[1..], Token::Comma)),
+        Some('-') => {
+            if let Ok((rest_n, tok)) = parse_numeric_token(rest) {
+                return Ok((rest_n, tok));
+            }
+            if let Some(rest_cdc) = rest.strip_prefix("-->") {
+                return Ok((rest_cdc, Token::CDC));
+            }
+            if let Ok((rest_id, token)) = parse_ident_like(rest) {
+                return Ok((rest_id, token));
+            }
+            Ok((&rest[1..], Token::Delim('-')))
+        }
+        Some('.') => {
+            if let Ok((rest_n, tok)) = parse_numeric_token(rest) {
+                return Ok((rest_n, tok));
+            }
+            Ok((&rest[1..], Token::Delim('.')))
+        }
+        Some(':') => Ok((&rest[1..], Token::Colon)),
+        Some('<') => {
+            if let Some(rest_cdo) = rest.strip_prefix("<!--") {
+                return Ok((rest_cdo, Token::CDO));
+            }
+            Ok((&rest[1..], Token::Delim('<')))
+        }
+        Some('@') => {
+            if let Ok((rest_id, id)) = parse_ident(rest) {
+                return Ok((rest_id, Token::AtKeyword(id.into())));
+            }
+            Ok((&rest[1..], Token::Delim('@')))
+        }
+        Some('[') => Ok((&rest[1..], Token::OpenSquare)),
+        Some('\\') => {
+            if let Ok((rest_id, token)) = parse_ident_like(rest) {
+                Ok((rest_id, token))
+            } else {
+                Ok((&rest[1..], Token::Delim('\\')))
+            }
+        }
+        Some(']') => Ok((&rest[1..], Token::CloseSquare)),
+        Some('{') => Ok((&rest[1..], Token::OpenBrace)),
+        Some('}') => Ok((&rest[1..], Token::CloseBrace)),
+        Some(c) if is_ident_start(c) => parse_ident_like(rest),
+        Some(c) if is_digit(c) => parse_numeric_token(rest),
+        Some('!') => Ok((&rest[1..], Token::Delim('!'))),
+        Some(c) => {
+            let num_bytes = c.len_utf8();
+            Ok((&rest[num_bytes..], Token::Delim(c)))
+        }
+    }
+}
+
+fn parse_token_not_semicolon(text: &str) -> IResult<&str, Token> {
+    let (rest, token) = parse_token(text)?;
+    if token == Token::Semicolon {
+        fail(text)
+    } else {
+        Ok((rest, token))
+    }
+}
+
+fn parse_value(text: &str) -> IResult<&str, RawValue> {
+    let (rest, mut tokens) = many0(parse_token_not_semicolon)(text)?;
+    let mut important = false;
+    if tokens.len() >= 2
+        && tokens[tokens.len() - 2..] == [Token::Delim('!'), Token::Ident("important".into())]
+    {
+        tokens.pop();
+        tokens.pop();
+        important = true;
+    }
+    Ok((rest, RawValue { tokens, important }))
+}
+
+pub(crate) fn parse_color_attribute(
+    text: &str,
+) -> Result<Colour, nom::Err<nom::error::Error<&'static str>>> {
+    let (_rest, value) = parse_value(text).map_err(|_| empty_fail())?;
+    parse_color(&value)
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub enum Importance {
+    Default,
+    Important,
+}
+
+pub fn parse_declaration(text: &str) -> IResult<&str, Option<Declaration>> {
+    let (rest, (prop, _ws1, _colon, _ws2, value)) = tuple((
+        parse_property_name,
+        skip_optional_whitespace,
+        tag(":"),
+        skip_optional_whitespace,
+        parse_value,
+    ))(text)?;
+    let decl = match prop.0.as_str() {
+        "background-color" => {
+            let value = parse_color(&value)?;
+            Decl::BackgroundColor { value }
+        }
+        "color" => {
+            let value = parse_color(&value)?;
+            Decl::Color { value }
+        }
+        "height" => {
+            let value = parse_height(&value)?;
+            Decl::Height { value }
+        }
+        "max-height" => {
+            let value = parse_height(&value)?;
+            Decl::MaxHeight { value }
+        }
+        "overflow" => {
+            let value = parse_overflow(&value)?;
+            Decl::Overflow { value }
+        }
+        "overflow-y" => {
+            let value = parse_overflow(&value)?;
+            Decl::OverflowY { value }
+        }
+        "display" => {
+            let value = parse_display(&value)?;
+            Decl::Display { value }
+        }
+        _ => Decl::Unknown {
+            name: prop,
+            //            value: /*value*/"".into(),
+        },
+    };
+    Ok((
+        rest,
+        Some(Declaration {
+            data: decl,
+            important: if value.important {
+                Importance::Important
+            } else {
+                Importance::Default
+            },
+        }),
+    ))
+}
+
+fn empty_fail() -> nom::Err<nom::error::Error<&'static str>> {
+    nom::Err::Error(nom::error::Error::new("", ErrorKind::Fail))
+}
+
+fn parse_color(value: &RawValue) -> Result<Colour, nom::Err<nom::error::Error<&'static str>>> {
+    let fail_error = empty_fail();
+    if value.tokens.is_empty() {
+        return Err(fail_error);
+    }
+    match &value.tokens[..] {
+        [Token::Ident(c)] => {
+            let colour = match c.deref() {
+                "aqua" => Colour::Rgb(0, 0xff, 0xff),
+                "black" => Colour::Rgb(0, 0, 0),
+                "blue" => Colour::Rgb(0, 0, 0xff),
+                "fuchsia" => Colour::Rgb(0xff, 0, 0xff),
+                "gray" => Colour::Rgb(0x80, 0x80, 0x80),
+                "green" => Colour::Rgb(0, 0x80, 0),
+                "lime" => Colour::Rgb(0, 0xff, 0),
+                "maroon" => Colour::Rgb(0x80, 0, 0),
+                "navy" => Colour::Rgb(0, 0, 0x80),
+                "olive" => Colour::Rgb(0x80, 0x80, 0),
+                "orange" => Colour::Rgb(0xff, 0xa5, 0),
+                "purple" => Colour::Rgb(0x80, 0, 0x80),
+                "red" => Colour::Rgb(0xff, 0, 0),
+                "silver" => Colour::Rgb(0xc0, 0xc0, 0xc0),
+                "teal" => Colour::Rgb(0, 0x80, 0x80),
+                "white" => Colour::Rgb(0xff, 0xff, 0xff),
+                "yellow" => Colour::Rgb(0xff, 0xff, 0),
+                _ => {
+                    return Err(empty_fail());
+                }
+            };
+            Ok(colour)
+        }
+        [Token::Function(name), .., Token::CloseRound] => {
+            use Token::*;
+            match name.deref() {
+                "rgb" => {
+                    let rgb_args = &value.tokens[1..value.tokens.len() - 1];
+                    match rgb_args {
+                        [Number(r), Comma, Number(g), Comma, Number(b)] => {
+                            let r = r.parse().map_err(|_e| empty_fail())?;
+                            let g = g.parse().map_err(|_e| empty_fail())?;
+                            let b = b.parse().map_err(|_e| empty_fail())?;
+                            Ok(Colour::Rgb(r, g, b))
+                        }
+                        _ => Err(empty_fail()),
+                    }
+                }
+                _ => Err(empty_fail()),
+            }
+        }
+        [Token::Hash(s)] => {
+            if s.len() == 3 {
+                let v = u32::from_str_radix(s, 16).map_err(|_| fail_error)?;
+                let r = ((v >> 8) & 0xf) as u8 * 0x11;
+                let g = ((v >> 4) & 0xf) as u8 * 0x11;
+                let b = (v & 0xf) as u8 * 0x11;
+                Ok(Colour::Rgb(r, g, b))
+            } else if s.len() == 6 {
+                let v = u32::from_str_radix(s, 16).map_err(|_| fail_error)?;
+                let r = ((v >> 16) & 0xff) as u8;
+                let g = ((v >> 8) & 0xff) as u8;
+                let b = (v & 0xff) as u8;
+                Ok(Colour::Rgb(r, g, b))
+            } else {
+                Err(fail_error)
+            }
+        }
+        _ => Err(fail_error),
+    }
+}
+
+fn parse_integer(text: &str) -> IResult<&str, f32> {
+    let (rest, digits) = digit1(text)?;
+    Ok((rest, <f32 as FromStr>::from_str(digits).unwrap()))
+}
+
+fn parse_decimal(text: &str) -> IResult<&str, f32> {
+    let (rest, valstr) = recognize(tuple((digit0, tag("."), digit1)))(text)?;
+    Ok((rest, <f32 as FromStr>::from_str(valstr).unwrap()))
+}
+
+fn parse_number(text: &str) -> IResult<&str, f32> {
+    let (rest, _) = skip_optional_whitespace(text)?;
+    let (rest, (sign, val)) = tuple((
+        opt(alt((tag("-"), tag("+")))),
+        alt((parse_integer, parse_decimal)),
+    ))(rest)?;
+    Ok((
+        rest,
+        match sign {
+            Some("-") => -val,
+            None | Some("+") => val,
+            _ => unreachable!(),
+        },
+    ))
+}
+
+fn parse_numeric_token(text: &str) -> IResult<&str, Token> {
+    let (rest, num) = recognize(parse_number)(text)?;
+    let match_pct: IResult<_, _> = tag("%")(rest);
+    if let Ok((rest_p, _)) = match_pct {
+        return Ok((rest_p, Token::Percentage(num.into())));
+    }
+    match parse_ident(rest) {
+        Ok((rest_id, dim)) => Ok((rest_id, Token::Dimension(num.into(), dim.into()))),
+        Err(_) => Ok((rest, Token::Number(num.into()))),
+    }
+}
+
+fn parse_ident_like(text: &str) -> IResult<&str, Token> {
+    let (rest, ident) = parse_ident(text)?;
+    // If the next character is '(', then it's a function token
+    let match_bracket: IResult<_, _> = tag("(")(rest);
+    match match_bracket {
+        Ok((rest_f, _)) => Ok((rest_f, Token::Function(ident.into()))),
+        Err(_) => Ok((rest, Token::Ident(ident.into()))),
+    }
+}
+
+fn parse_string_token(text: &str) -> IResult<&str, Token> {
+    let mut chars = text.char_indices();
+    let mut s = String::new();
+    let end_char = chars.next().unwrap().1;
+    debug_assert!(end_char == '"' || end_char == '\'');
+
+    loop {
+        match chars.next() {
+            None => return Ok((&text[text.len()..], Token::String(s.into()))),
+            Some((i, c)) if c == end_char => {
+                return Ok((&text[i + 1..], Token::String(s.into())));
+            }
+            Some((i, '\n')) => {
+                return Ok((&text[i..], Token::BadString(s.into())));
+            }
+            Some((i, '\\')) => {
+                match chars.next() {
+                    None => {
+                        // Backslash at end
+                        return Ok((&text[i + 1..], Token::String(s.into())));
+                    }
+                    Some((_i, '\n')) => {} // Eat the newline
+                    Some((_i, c)) => {
+                        s.push(c);
+                    }
+                }
+            }
+            Some((_, c)) => {
+                s.push(c);
+            }
+        }
+    }
+}
+
+/*
+fn parse_unit(text: &str) -> IResult<&str, LengthUnit> {
+    let (rest, word) = alpha0(text)?;
+    Ok((rest, match word {
+        _ => {
+            return fail(text);
+        }
+    }))
+}
+*/
+
+fn parse_height(value: &RawValue) -> Result<Height, nom::Err<nom::error::Error<&'static str>>> {
+    match value.tokens[..] {
+        [Token::Dimension(ref n, ref unit)] => {
+            let (_, num) = parse_number(n).map_err(|_e| empty_fail())?;
+            let unit = match unit.deref() {
+                "in" => LengthUnit::In,
+                "cm" => LengthUnit::Cm,
+                "mm" => LengthUnit::Mm,
+                "pt" => LengthUnit::Pt,
+                "pc" => LengthUnit::Pc,
+                "px" => LengthUnit::Px,
+                "em" => LengthUnit::Em,
+                "ex" => LengthUnit::Ex,
+                _ => {
+                    return Err(empty_fail());
+                }
+            };
+            Ok(Height::Length(num, unit))
+        }
+        [Token::Number(ref n)] => {
+            let (_, n) = parse_number(n).map_err(|_e| empty_fail())?;
+            if n == 0.0 {
+                Ok(Height::Length(0.0, LengthUnit::Px))
+            } else {
+                Err(empty_fail())
+            }
+        }
+        _ => Err(empty_fail()),
+    }
+}
+
+fn parse_overflow(value: &RawValue) -> Result<Overflow, nom::Err<nom::error::Error<&'static str>>> {
+    for tok in &value.tokens {
+        if let Token::Ident(word) = tok {
+            match word.deref() {
+                "visible" => {
+                    return Ok(Overflow::Visible);
+                }
+                "hidden" => {
+                    return Ok(Overflow::Hidden);
+                }
+                "scroll" => {
+                    return Ok(Overflow::Scroll);
+                }
+                "auto" => {
+                    return Ok(Overflow::Auto);
+                }
+                _ => {}
+            }
+        }
+    }
+    Err(empty_fail())
+}
+
+fn parse_display(value: &RawValue) -> Result<Display, nom::Err<nom::error::Error<&'static str>>> {
+    for tok in &value.tokens {
+        if let Token::Ident(word) = tok {
+            #[allow(clippy::single_match)]
+            match word.deref() {
+                "none" => return Ok(Display::None),
+                _ => (),
+            }
+        }
+    }
+    Ok(Display::Other)
+}
+
+pub fn parse_rules(text: &str) -> IResult<&str, Vec<Declaration>> {
+    separated_list0(
+        tuple((tag(";"), skip_optional_whitespace)),
+        parse_declaration,
+    )(text)
+    .map(|(rest, v)| (rest, v.into_iter().flatten().collect()))
+}
+
+fn parse_class(text: &str) -> IResult<&str, SelectorComponent> {
+    let (rest, _) = tag(".")(text)?;
+    let (rest, classname) = parse_ident(rest)?;
+    Ok((rest, SelectorComponent::Class(classname)))
+}
+
+fn parse_hash(text: &str) -> IResult<&str, SelectorComponent> {
+    let (rest, _) = tag("#")(text)?;
+    let (rest, word) = parse_identstring(rest)?;
+    Ok((rest, SelectorComponent::Hash(word)))
+}
+
+// Match some (not zero) whitespace
+fn parse_ws(text: &str) -> IResult<&str, ()> {
+    map(many1(match_whitespace_item), |_| ())(text)
+}
+
+fn parse_simple_selector_component(text: &str) -> IResult<&str, SelectorComponent> {
+    alt((
+        map(
+            tuple((skip_optional_whitespace, tag(">"), skip_optional_whitespace)),
+            |_| SelectorComponent::CombChild,
+        ),
+        map(
+            tuple((skip_optional_whitespace, tag("*"), skip_optional_whitespace)),
+            |_| SelectorComponent::Star,
+        ),
+        map(parse_ws, |_| SelectorComponent::CombDescendant),
+        parse_class,
+        parse_hash,
+        map(parse_ident, SelectorComponent::Element),
+    ))(text)
+}
+
+fn parse_selector_with_element(text: &str) -> IResult<&str, Vec<SelectorComponent>> {
+    let (rest, ident) = parse_ident(text)?;
+    let (rest, extras) = many0(parse_simple_selector_component)(rest)?;
+    let mut result = vec![SelectorComponent::Element(ident)];
+    result.extend(extras);
+    Ok((rest, result))
+}
+
+fn parse_selector_without_element(text: &str) -> IResult<&str, Vec<SelectorComponent>> {
+    many1(parse_simple_selector_component)(text)
+}
+
+fn parse_selector(text: &str) -> IResult<&str, Selector> {
+    let (rest, mut components) = alt((
+        parse_selector_with_element,
+        parse_selector_without_element,
+        fail,
+    ))(text)?;
+    // Reverse.  Also remove any leading/trailing CombDescendent, as leading/trailing whitespace
+    // shouldn't count as a descendent combinator.
+    if let Some(&SelectorComponent::CombDescendant) = components.last() {
+        components.pop();
+    }
+    components.reverse();
+    if let Some(&SelectorComponent::CombDescendant) = components.last() {
+        components.pop();
+    }
+    Ok((rest, Selector { components }))
+}
+
+fn parse_ruleset(text: &str) -> IResult<&str, RuleSet> {
+    let (rest, _) = skip_optional_whitespace(text)?;
+    let (rest, selectors) =
+        separated_list0(tuple((tag(","), skip_optional_whitespace)), parse_selector)(rest)?;
+    let (rest, (_ws1, _bra, _ws2, declarations, _ws3, _optsemi, _ws4, _ket, _ws5)) = tuple((
+        skip_optional_whitespace,
+        tag("{"),
+        skip_optional_whitespace,
+        parse_rules,
+        skip_optional_whitespace,
+        opt(tag(";")),
+        skip_optional_whitespace,
+        tag("}"),
+        skip_optional_whitespace,
+    ))(rest)?;
+    Ok((
+        rest,
+        RuleSet {
+            selectors,
+            declarations,
+        },
+    ))
+}
+
+pub(crate) fn parse_stylesheet(text: &str) -> IResult<&str, Vec<RuleSet>> {
+    many0(parse_ruleset)(text)
+}
+
+#[cfg(test)]
+mod test {
+    use crate::css::{
+        parser::{Height, Importance, LengthUnit, RuleSet, Selector},
+        SelectorComponent,
+    };
+
+    use super::{Colour, Decl, Declaration, Overflow, PropertyName};
+
+    #[test]
+    fn test_parse_decl() {
+        assert_eq!(
+            super::parse_declaration("foo:bar;"),
+            Ok((
+                ";",
+                Some(Declaration {
+                    data: Decl::Unknown {
+                        name: PropertyName("foo".into()),
+                        //                value: "bar".into()
+                    },
+                    important: Importance::Default,
+                })
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_overflow() {
+        assert_eq!(
+            super::parse_rules("overflow: hidden; overflow-y: scroll"),
+            Ok((
+                "",
+                vec![
+                    Declaration {
+                        data: Decl::Overflow {
+                            value: Overflow::Hidden
+                        },
+                        important: Importance::Default,
+                    },
+                    Declaration {
+                        data: Decl::OverflowY {
+                            value: Overflow::Scroll
+                        },
+                        important: Importance::Default,
+                    },
+                ]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_color() {
+        assert_eq!(
+            super::parse_rules("color: #123; color: #abcdef"),
+            Ok((
+                "",
+                vec![
+                    Declaration {
+                        data: Decl::Color {
+                            value: Colour::Rgb(0x11, 0x22, 0x33)
+                        },
+                        important: Importance::Default,
+                    },
+                    Declaration {
+                        data: Decl::Color {
+                            value: Colour::Rgb(0xab, 0xcd, 0xef)
+                        },
+                        important: Importance::Default,
+                    },
+                ]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_height() {
+        assert_eq!(
+            super::parse_rules("height: 0; max-height: 100cm"),
+            Ok((
+                "",
+                vec![
+                    Declaration {
+                        data: Decl::Height {
+                            value: Height::Length(0.0, LengthUnit::Px),
+                        },
+                        important: Importance::Default,
+                    },
+                    Declaration {
+                        data: Decl::MaxHeight {
+                            value: Height::Length(100.0, LengthUnit::Cm),
+                        },
+                        important: Importance::Default,
+                    },
+                ]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_empty_ss() {
+        assert_eq!(super::parse_stylesheet(""), Ok(("", vec![])));
+    }
+
+    #[test]
+    fn test_parse_ss_col() {
+        assert_eq!(
+            super::parse_stylesheet(
+                "
+            foo {
+                color: #112233;
+            }
+            "
+            ),
+            Ok((
+                "",
+                vec![RuleSet {
+                    selectors: vec![Selector {
+                        components: vec![SelectorComponent::Element("foo".into()),],
+                    },],
+                    declarations: vec![Declaration {
+                        data: Decl::Color {
+                            value: Colour::Rgb(0x11, 0x22, 0x33)
+                        },
+                        important: Importance::Default,
+                    },],
+                }]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_class() {
+        assert_eq!(
+            super::parse_stylesheet(
+                "
+            .foo {
+                color: #112233;
+                background-color: #332211 !important;
+            }
+            "
+            ),
+            Ok((
+                "",
+                vec![RuleSet {
+                    selectors: vec![Selector {
+                        components: vec![SelectorComponent::Class("foo".into()),],
+                    },],
+                    declarations: vec![
+                        Declaration {
+                            data: Decl::Color {
+                                value: Colour::Rgb(0x11, 0x22, 0x33)
+                            },
+                            important: Importance::Default,
+                        },
+                        Declaration {
+                            data: Decl::BackgroundColor {
+                                value: Colour::Rgb(0x33, 0x22, 0x11)
+                            },
+                            important: Importance::Important,
+                        },
+                    ],
+                }]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_named_colour() {
+        assert_eq!(
+            super::parse_declaration("color: white"),
+            Ok((
+                "",
+                Some(Declaration {
+                    data: Decl::Color {
+                        value: Colour::Rgb(0xff, 0xff, 0xff)
+                    },
+                    important: Importance::Default,
+                })
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_colour_func() {
+        assert_eq!(
+            super::parse_declaration("color: rgb(1, 2, 3)"),
+            Ok((
+                "",
+                Some(Declaration {
+                    data: Decl::Color {
+                        value: Colour::Rgb(1, 2, 3)
+                    },
+                    important: Importance::Default,
+                })
+            ))
+        );
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 0c8010c..c42156b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -47,8 +47,6 @@
 //! read keys from stdin.
 //!
 
-#![cfg_attr(feature = "clippy", feature(plugin))]
-#![cfg_attr(feature = "clippy", plugin(clippy))]
 #![deny(missing_docs)]
 
 // Check code in README.md
@@ -69,10 +67,7 @@ pub mod render;
 use render::text_renderer::{
     RenderLine, RenderOptions, RichAnnotation, SubRenderer, TaggedLine, TextRenderer,
 };
-use render::PlainDecorator;
-use render::Renderer;
-use render::RichDecorator;
-use render::TextDecorator;
+use render::{Renderer, RichDecorator, TextDecorator};
 
 use html5ever::driver::ParseOpts;
 use html5ever::parse_document;
@@ -88,11 +83,6 @@ use std::cell::Cell;
 use std::cmp::{max, min};
 use unicode_width::UnicodeWidthStr;
 
-#[cfg(feature = "css")]
-use lightningcss::values::color::CssColor;
-#[cfg(feature = "css")]
-use std::convert::TryFrom;
-
 use std::io;
 use std::io::Write;
 use std::iter::{once, repeat};
@@ -108,22 +98,6 @@ pub struct Colour {
     pub b: u8,
 }
 
-#[cfg(feature = "css")]
-impl TryFrom<&CssColor> for Colour {
-    type Error = ();
-
-    fn try_from(value: &CssColor) -> std::result::Result<Self, Self::Error> {
-        match value {
-            CssColor::RGBA(rgba) => Ok(Colour {
-                r: rgba.red,
-                g: rgba.green,
-                b: rgba.blue,
-            }),
-            _ => Err(()),
-        }
-    }
-}
-
 /// Errors from reading or rendering HTML
 #[derive(thiserror::Error, Debug)]
 #[non_exhaustive]
@@ -224,16 +198,6 @@ struct RenderTableCell {
 }
 
 impl RenderTableCell {
-    /// Render this cell to a renderer.
-    fn render<T: Write, D: TextDecorator>(
-        &mut self,
-        _renderer: &mut TextRenderer<D>,
-        _err_out: &mut T,
-    ) {
-        unimplemented!()
-        //render_tree_children_to_string(builder, &mut self.content, err_out)
-    }
-
     /// Calculate or return the estimate size of the cell
     fn get_size_estimate(&self) -> SizeEstimate {
         if self.size_estimate.get().is_none() {
@@ -269,18 +233,6 @@ impl RenderTableRow {
     fn num_cells(&self) -> usize {
         self.cells.iter().map(|cell| cell.colspan.max(1)).sum()
     }
-    /// Return an iterator over (column, &cell)s, which
-    /// takes into account colspan.
-    fn cell_columns(&mut self) -> Vec<(usize, &mut RenderTableCell)> {
-        let mut result = Vec::new();
-        let mut colno = 0;
-        for cell in &mut self.cells {
-            let colspan = cell.colspan;
-            result.push((colno, cell));
-            colno += colspan;
-        }
-        result
-    }
 
     /// Return the contained cells as RenderNodes, annotated with their
     /// widths if available.  Skips cells with no width allocated.
@@ -330,10 +282,6 @@ impl RenderTable {
         self.rows.iter()
     }
 
-    /// Return an iterator over the rows.
-    fn rows_mut(&mut self) -> std::slice::IterMut<RenderTableRow> {
-        self.rows.iter_mut()
-    }
     /// Consume this and return a `Vec<RenderNode>` containing the children;
     /// the children know the column sizes required.
     fn into_rows(self, col_sizes: Vec<usize>, vert: bool) -> Vec<RenderNode> {
@@ -383,11 +331,6 @@ impl RenderTable {
         self.size_estimate.set(Some(result));
         result
     }
-
-    /// Calculate and store (or return stored value) of estimated size
-    fn get_size_estimate(&self) -> SizeEstimate {
-        self.size_estimate.get().unwrap()
-    }
 }
 
 /// The node-specific information distilled from the DOM.
@@ -445,8 +388,10 @@ enum RenderNodeInfo {
     /// Start of a named HTML fragment
     FragStart(String),
     /// A region with a foreground colour
+    #[allow(unused)]
     Coloured(Colour, Vec<RenderNode>),
     /// A region with a background colour
+    #[allow(unused)]
     BgColoured(Colour, Vec<RenderNode>),
     /// A list item
     ListItem(Vec<RenderNode>),
@@ -759,9 +704,9 @@ fn desc_list_children_to_render_nodes<T: Write>(
 }
 
 /// Convert a table into a RenderNode
-fn table_to_render_tree<'a, 'b, T: Write>(
+fn table_to_render_tree<'a, T: Write>(
     handle: Handle,
-    _err_out: &'b mut T,
+    _err_out: &mut T,
 ) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode> {
     pending(handle, |_, rowset| {
         let mut rows = vec![];
@@ -783,9 +728,9 @@ fn table_to_render_tree<'a, 'b, T: Write>(
 }
 
 /// Add rows from a thead or tbody.
-fn tbody_to_render_tree<'a, 'b, T: Write>(
+fn tbody_to_render_tree<'a, T: Write>(
     handle: Handle,
-    _err_out: &'b mut T,
+    _err_out: &mut T,
 ) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode> {
     pending_noempty(handle, |_, rowchildren| {
         let mut rows = rowchildren
@@ -832,9 +777,9 @@ fn tbody_to_render_tree<'a, 'b, T: Write>(
 }
 
 /// Convert a table row to a RenderTableRow
-fn tr_to_render_tree<'a, 'b, T: Write>(
+fn tr_to_render_tree<'a, T: Write>(
     handle: Handle,
-    _err_out: &'b mut T,
+    _err_out: &mut T,
 ) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode> {
     pending(handle, |_, cellnodes| {
         let cells = cellnodes
@@ -859,9 +804,9 @@ fn tr_to_render_tree<'a, 'b, T: Write>(
 }
 
 /// Convert a single table cell to a render node.
-fn td_to_render_tree<'a, 'b, T: Write>(
+fn td_to_render_tree<'a, T: Write>(
     handle: Handle,
-    _err_out: &'b mut T,
+    _err_out: &mut T,
 ) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode> {
     let mut colspan = 1;
     if let Element { ref attrs, .. } = handle.data {
@@ -1079,11 +1024,6 @@ fn dom_to_render_tree_with_context<T: Write>(
     result
 }
 
-/// Convert a DOM tree or subtree into a render tree.
-fn dom_to_render_tree<T: Write>(handle: Handle, err_out: &mut T) -> Result<Option<RenderNode>> {
-    dom_to_render_tree_with_context(handle, err_out, &mut Default::default())
-}
-
 fn pending<'a, F>(handle: Handle, f: F) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode>
 where
     for<'r> F:
@@ -1180,11 +1120,11 @@ fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
     orig
 }
 
-fn process_dom_node<'a, 'b, 'c, T: Write>(
+fn process_dom_node<'a, T: Write>(
     handle: Handle,
-    err_out: &'b mut T,
+    err_out: &mut T,
     #[allow(unused)] // Used with css feature
-    context: &'c mut HtmlContext,
+    context: &mut HtmlContext,
 ) -> Result<TreeMapResult<'a, HtmlContext, Handle, RenderNode>> {
     use RenderNodeInfo::*;
     use TreeMapResult::*;
@@ -1212,15 +1152,11 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                     .matching_rules(&handle, context.use_doc_css)
                 {
                     match style {
-                        css::Style::Colour(col) => {
-                            if let Ok(colour) = Colour::try_from(&col) {
-                                css_colour = Some(colour);
-                            }
+                        css::Style::Colour(colour) => {
+                            css_colour = Some(colour);
                         }
-                        css::Style::BgColour(col) => {
-                            if let Ok(colour) = Colour::try_from(&col) {
-                                css_bgcolour = Some(colour);
-                            }
+                        css::Style::BgColour(colour) => {
+                            css_bgcolour = Some(colour);
                         }
                         css::Style::DisplayNone => {
                             return Ok(Nothing);
@@ -1415,14 +1351,12 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
 
             #[cfg(feature = "css")]
             let result = if let Some(colour) = css_colour {
-                let colour = colour.into();
                 result.wrap_render_nodes(move |children| Coloured(colour, children))
             } else {
                 result
             };
             #[cfg(feature = "css")]
             let result = if let Some(colour) = css_bgcolour {
-                let colour = colour.into();
                 result.wrap_render_nodes(move |children| BgColoured(colour, children))
             } else {
                 result
@@ -1487,10 +1421,10 @@ fn pending2<
     }
 }
 
-fn do_render_node<'b, T: Write, D: TextDecorator>(
+fn do_render_node<T: Write, D: TextDecorator>(
     renderer: &mut TextRenderer<D>,
     tree: RenderNode,
-    err_out: &'b mut T,
+    err_out: &mut T,
 ) -> Result<TreeMapResult<'static, TextRenderer<D>, RenderNode, Option<SubRenderer<D>>>> {
     html_trace!("do_render_node({:?}", tree);
     use RenderNodeInfo::*;
@@ -1726,7 +1660,7 @@ fn do_render_node<'b, T: Write, D: TextDecorator>(
         Sup(children) => {
             // Special case for digit-only superscripts - use superscript
             // characters.
-            fn sup_digits(children: &Vec<RenderNode>) -> Option<String> {
+            fn sup_digits(children: &[RenderNode]) -> Option<String> {
                 if children.len() != 1 {
                     return None;
                 }
@@ -2266,13 +2200,13 @@ impl RenderTree {
         if width == 0 {
             return Err(Error::TooNarrow);
         }
-        let mut render_options = RenderOptions::default();
-        render_options.wrap_width = context.max_wrap_width;
-        render_options.pad_block_width = context.pad_block_width;
-        render_options.min_wrap_width = context.min_wrap_width;
-        render_options.allow_width_overflow = context.allow_width_overflow;
-        render_options.raw = context.raw;
-        render_options.draw_borders = context.draw_borders;
+        let render_options = RenderOptions {
+            wrap_width: context.max_wrap_width,
+            pad_block_width: context.pad_block_width,
+            allow_width_overflow: context.allow_width_overflow,
+            raw: context.raw,
+            draw_borders: context.draw_borders,
+        };
         let test_decorator = decorator.make_subblock_decorator();
         let builder = SubRenderer::new(width, render_options, decorator);
         let builder =
@@ -2284,22 +2218,6 @@ impl RenderTree {
     fn render<D: TextDecorator>(self, width: usize, decorator: D) -> Result<RenderedText<D>> {
         self.render_with_context(&mut Default::default(), width, decorator)
     }
-
-    /// Render this document as plain text using the [`PlainDecorator`][] and wrap it to `width`
-    /// columns.
-    ///
-    /// [`PlainDecorator`]: render/text_renderer/struct.PlainDecorator.html
-    fn render_plain(self, width: usize) -> Result<RenderedText<PlainDecorator>> {
-        self.render(width, PlainDecorator::new())
-    }
-
-    /// Render this document as rich text using the [`RichDecorator`][] and wrap it to `width`
-    /// columns.
-    ///
-    /// [`RichDecorator`]: render/text_renderer/struct.RichDecorator.html
-    fn render_rich(self, width: usize) -> Result<RenderedText<RichDecorator>> {
-        self.render(width, RichDecorator::new())
-    }
 }
 
 /// A rendered HTML document.
diff --git a/src/render/text_renderer.rs b/src/render/text_renderer.rs
index 32dc201..4c1cee9 100644
--- a/src/render/text_renderer.rs
+++ b/src/render/text_renderer.rs
@@ -132,6 +132,12 @@ pub struct TaggedLine<T> {
     v: Vec<TaggedLineElement<T>>,
 }
 
+impl<T: Debug + Eq + PartialEq + Clone + Default> Default for TaggedLine<T> {
+    fn default() -> Self {
+        TaggedLine::new()
+    }
+}
+
 impl<T: Debug + Eq + PartialEq + Clone + Default> TaggedLine<T> {
     /// Create an empty `TaggedLine`.
     pub fn new() -> TaggedLine<T> {
@@ -231,8 +237,8 @@ impl<T: Debug + Eq + PartialEq + Clone + Default> TaggedLine<T> {
     }
 
     /// Iterator over the chars in this line.
-    #[cfg_attr(feature = "clippy", allow(needless_lifetimes))]
-    fn chars<'a>(&'a self) -> impl Iterator<Item = char> + 'a {
+    #[allow(clippy::needless_lifetimes)]
+    pub fn chars<'a>(&'a self) -> impl Iterator<Item = char> + 'a {
         use self::TaggedLineElement::Str;
 
         self.v.iter().flat_map(|tle| {
@@ -251,7 +257,7 @@ impl<T: Debug + Eq + PartialEq + Clone + Default> TaggedLine<T> {
     }
 
     /// Iterator over TaggedLineElements
-    pub fn iter<'a>(&'a self) -> impl Iterator<Item = &TaggedLineElement<T>> + 'a {
+    pub fn iter(&self) -> impl Iterator<Item = &TaggedLineElement<T>> + '_ {
         self.v.iter()
     }
 
@@ -815,6 +821,7 @@ impl<T: Clone> BorderHoriz<T> {
     }
 
     /// Return a string without destroying self
+    #[allow(clippy::inherent_to_string)]
     fn to_string(&self) -> String {
         self.clone().into_string()
     }
@@ -917,9 +924,6 @@ pub(crate) struct RenderOptions {
     /// blocks or side-by-side table cells).
     pub wrap_width: Option<usize>,
 
-    /// The minimum text width to use when wrapping.
-    pub min_wrap_width: usize,
-
     /// If true, then allow the output to be wider than specified instead of returning
     /// `Err(TooNarrow)`.
     pub allow_width_overflow: bool,
@@ -941,7 +945,6 @@ impl Default for RenderOptions {
     fn default() -> Self {
         Self {
             wrap_width: Default::default(),
-            min_wrap_width: crate::MIN_WIDTH,
             allow_width_overflow: Default::default(),
             pad_block_width: Default::default(),
             raw: false,
@@ -1628,7 +1631,7 @@ pub struct PlainDecorator {
 
 impl PlainDecorator {
     /// Create a new `PlainDecorator`.
-    #[cfg_attr(feature = "clippy", allow(new_without_default_derive))]
+    #[allow(clippy::new_without_default)]
     pub fn new() -> PlainDecorator {
         PlainDecorator {
             nlinks: Rc::new(Cell::new(0)),
@@ -1723,7 +1726,7 @@ pub struct TrivialDecorator {}
 
 impl TrivialDecorator {
     /// Create a new `TrivialDecorator`.
-    #[cfg_attr(feature = "clippy", allow(new_without_default_derive))]
+    #[allow(clippy::new_without_default)]
     pub fn new() -> TrivialDecorator {
         TrivialDecorator {}
     }
@@ -1840,7 +1843,7 @@ pub enum RichAnnotation {
 
 impl RichDecorator {
     /// Create a new `RichDecorator`.
-    #[cfg_attr(feature = "clippy", allow(new_without_default_derive))]
+    #[allow(clippy::new_without_default)]
     pub fn new() -> RichDecorator {
         RichDecorator {}
     }


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
index d1d5ea0..46a7e4d 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -86,6 +86,13 @@ fn test_colour_map(annotations: &[RichAnnotation], s: &str) -> String {
                 } => {
                     tags = ("<G>", "</G>");
                 }
+                crate::Colour {
+                    r: 0,
+                    g: 0,
+                    b: 0xff,
+                } => {
+                    tags = ("<B>", "</B>");
+                }
                 _ => {
                     tags = ("<?>", "</?>");
                 }
@@ -1331,41 +1338,26 @@ fn test_multi_parse() {
     assert_eq!(
         "one two three four five six seven eight nine ten eleven twelve thirteen fourteen\n\
          fifteen sixteen seventeen\n",
-        tree.clone()
-            .render_plain(80)
-            .unwrap()
-            .into_string()
-            .unwrap()
+        config::plain().render_to_string(tree.clone(), 80).unwrap()
     );
     assert_eq!(
         "one two three four five six seven eight nine ten eleven twelve\n\
          thirteen fourteen fifteen sixteen seventeen\n",
-        tree.clone()
-            .render_plain(70)
-            .unwrap()
-            .into_string()
-            .unwrap()
+        config::plain().render_to_string(tree.clone(), 70).unwrap()
     );
     assert_eq!(
         "one two three four five six seven eight nine ten\n\
          eleven twelve thirteen fourteen fifteen sixteen\n\
          seventeen\n",
-        tree.clone()
-            .render_plain(50)
-            .unwrap()
-            .into_string()
-            .unwrap()
+        config::plain().render_to_string(tree.clone(), 50).unwrap()
     );
 }
 
 #[test]
 fn test_read_rich() {
     let html: &[u8] = b"<strong>bold</strong>";
-    let lines = parse(html)
-        .unwrap()
-        .render_rich(80)
-        .unwrap()
-        .into_lines()
+    let lines = config::rich()
+        .render_to_lines(parse(html).unwrap(), 80)
         .unwrap();
     let tag = vec![RichAnnotation::Strong];
     let line = TaggedLine::from_string("*bold*".to_owned(), &tag);
@@ -1375,11 +1367,8 @@ fn test_read_rich() {
 #[test]
 fn test_read_custom() {
     let html: &[u8] = b"<strong>bold</strong>";
-    let lines = parse(html)
-        .unwrap()
-        .render(80, TrivialDecorator::new())
-        .unwrap()
-        .into_lines()
+    let lines = config::with_decorator(TrivialDecorator::new())
+        .render_to_lines(parse(html).unwrap(), 80)
         .unwrap();
     let tag = vec![()];
     let line = TaggedLine::from_string("bold".to_owned(), &tag);
@@ -1390,11 +1379,8 @@ fn test_read_custom() {
 fn test_pre_rich() {
     use RichAnnotation::*;
     assert_eq!(
-        crate::parse("<pre>test</pre>".as_bytes())
-            .unwrap()
-            .render_rich(100)
-            .unwrap()
-            .into_lines()
+        config::rich()
+            .render_to_lines(parse(&b"<pre>test</pre>"[..]).unwrap(), 100)
             .unwrap(),
         [TaggedLine::from_string(
             "test".into(),
@@ -1403,11 +1389,8 @@ fn test_pre_rich() {
     );
 
     assert_eq!(
-        crate::parse("<pre>testlong</pre>".as_bytes())
-            .unwrap()
-            .render_rich(4)
-            .unwrap()
-            .into_lines()
+        config::rich()
+            .render_to_lines(crate::parse("<pre>testlong</pre>".as_bytes()).unwrap(), 4)
             .unwrap(),
         [
             TaggedLine::from_string("test".into(), &vec![Preformat(false)]),
@@ -2347,6 +2330,64 @@ text
         <div style="height: 0; overflow: hidden">This should be hidden</div>
         <p>Hello</p>"#,
             r#"Hello
+"#,
+            20,
+        );
+    }
+
+    #[test]
+    fn test_selector_hash() {
+        test_html_coloured(
+            br#"<head><style>
+        #foo {
+            color: #f00;
+        }
+        p#bar {
+            color: #0f0;
+        }
+        div#baz {
+            color: #00f;
+        }
+        *#qux {
+            color: #fff;
+        }
+        </style></head><body>
+
+        <p id="foo">Foo</p>
+        <p id="bar">Bar</p>
+        <p id="baz">Baz</p>
+        <p id="qux">Qux</p>
+        "#,
+            r#"<R>Foo</R>
+
+<G>Bar</G>
+
+Baz
+
+<W>Qux</W>
+"#,
+            20,
+        );
+    }
+
+    #[test]
+    fn test_selector_child_desc() {
+        test_html_coloured(
+            br#"<head><style>
+        p.d span { /* descendent */
+            color: #f00;
+        }
+        p.c > span { /* child */
+            color: #0f0;
+        }
+        </style></head><body>
+
+        <p class="d">X<span>C</span><dummy><span>D</span></dummy>Y</p>
+        <p class="c"><span>C</span><dummy><span>D</span></dummy></p>
+        "#,
+            r#"X<R>CD</R>Y
+
+<G>C</G>D
 "#,
             20,
         );