csskit_transform 0.0.21-canary.fc80f623b7

AST transformation and minification utilities for CSS.
Documentation
use bumpalo::{Bump, collections::Vec};
use css_lexer::{AtomSet, Cursor, DynAtomSet, Lexer, ToSpan};
use css_parse::{
	CursorOverlaySet, CursorToSourceCursorSink, NodeMetadata, NodeWithMetadata, OverlayKind, OverlaySegment, Parse,
	Parser, SourceCursor, SourceOffset, Span, ToCursors,
};
use std::{cell::RefCell, marker::PhantomData};

#[derive(Debug)]
pub enum TransformEdit<'a> {
	Replace { target: Span, cursors: Vec<'a, SourceCursor<'a>> },
	InsertBefore { anchor: SourceOffset, cursors: Vec<'a, SourceCursor<'a>> },
	InsertAfter { anchor: SourceOffset, cursors: Vec<'a, SourceCursor<'a>> },
	Delete { target: Span },
}

#[derive(Debug)]
pub enum CommitError {
	OverlappingEdit { previous: Span, new: Span },
	InvalidEdit { span: Span },
}

struct PendingSegment<'a> {
	span: Span,
	intent: OverlayKind,
	order: usize,
	cursors: Vec<'a, SourceCursor<'a>>,
}

pub trait TransformerFeatures<M, N>: Sized + Default + Copy {
	fn transforms<'a, 'ctx>(self, transformer: &'ctx Transformer<'a, M, N, Self>, node: &N)
	where
		M: NodeMetadata,
		N: NodeWithMetadata<M>;
}

pub struct Transformer<'a, M: NodeMetadata, N: NodeWithMetadata<M>, F: TransformerFeatures<M, N>> {
	bump: &'a Bump,
	atoms: &'static dyn DynAtomSet,
	pub(crate) features: F,
	changed: RefCell<bool>,
	overlays: RefCell<CursorOverlaySet<'a>>,
	edits: RefCell<Vec<'a, TransformEdit<'a>>>,
	pub(crate) source_text: &'a str,
	_phantom: PhantomData<(M, N)>,
}

impl<'a, M: NodeMetadata, N: NodeWithMetadata<M>, F: TransformerFeatures<M, N>> Transformer<'a, M, N, F> {
	pub fn new_in(bump: &'a Bump, features: F, atoms: &'static dyn DynAtomSet, source_text: &'a str) -> Self {
		Self {
			bump,
			features,
			atoms,
			changed: RefCell::new(false),
			overlays: RefCell::new(CursorOverlaySet::new(bump)),
			edits: RefCell::new(Vec::new_in(bump)),
			source_text,
			_phantom: PhantomData,
		}
	}

	pub fn transform(&mut self, node: &mut N) {
		self.reset();
		self.features.transforms(self, node);
		if let Err(err) = self.commit_overlays() {
			panic!("Transform overlays commit failed: {:?}", err);
		}
	}

	pub fn has_changed(&self) -> bool {
		*self.changed.borrow()
	}

	pub fn bump(&self) -> &'a Bump {
		self.bump
	}

	pub fn to_source_cursor(&self, cursor: Cursor) -> SourceCursor<'a> {
		SourceCursor::from(cursor, cursor.str_slice(self.source_text))
	}

	pub fn to_source_cursors(&self, parsed: &impl ToCursors) -> Vec<'a, SourceCursor<'a>> {
		let mut cursors = Vec::new_in(self.bump());
		let mut sink = CursorToSourceCursorSink::new(self.source_text, &mut cursors);
		parsed.to_cursors(&mut sink);
		cursors
	}

	pub fn to_atom<A: AtomSet + PartialEq>(&self, c: Cursor) -> A {
		let bits = c.atom_bits();
		if bits == 0 {
			let source_cursor = self.to_source_cursor(c);
			return A::from_str(&source_cursor.parse(self.bump));
		}
		A::from_bits(bits)
	}

	pub fn overlays(&self) -> std::cell::Ref<'_, CursorOverlaySet<'a>> {
		self.overlays.borrow()
	}

	pub fn parse_value<T>(&self, source: &'a str) -> Vec<'a, SourceCursor<'a>>
	where
		T: Parse<'a> + ToCursors,
	{
		let lexer = Lexer::new(self.atoms, source);
		let mut parser = Parser::new(self.bump, source, lexer);
		let parsed = parser.parse_entirely::<T>();
		debug_assert!(
			parsed.output.is_some(),
			"Transformer::parse_value failed to parse {:?}: {:?}",
			source,
			parsed.errors
		);
		let mut cursors = Vec::new_in(self.bump());
		let mut sink = CursorToSourceCursorSink::new(source, &mut cursors);
		parsed.to_cursors(&mut sink);
		cursors
	}

	pub fn reset(&self) {
		*self.changed.borrow_mut() = false;
		self.overlays.borrow_mut().clear();
		self.edits.borrow_mut().clear();
	}

	pub fn has_replacement(&self, span: impl ToSpan) -> bool {
		self.overlays.borrow().has_overlay(span.to_span())
	}

	pub fn clear_pending_edits(&self, span: Span) -> bool {
		let mut edits = self.edits.borrow_mut();
		let len_before = edits.len();
		edits.retain(|edit| match edit {
			TransformEdit::Replace { target, .. } | TransformEdit::Delete { target } => target != &span,
			_ => true,
		});
		len_before != edits.len()
	}

	pub fn replace(&self, span: impl ToSpan, cursors: Vec<'a, SourceCursor<'a>>) {
		let span = span.to_span();
		debug_assert!(span.start() <= span.end(), "Transformer::replace received invalid span: {:?}", span);
		*self.changed.borrow_mut() = true;
		self.edits.borrow_mut().push(TransformEdit::Replace { target: span, cursors });
	}

	pub fn delete(&self, span: impl ToSpan) {
		let span = span.to_span();
		debug_assert!(span.start() <= span.end(), "Transformer::delete received invalid span: {:?}", span);
		*self.changed.borrow_mut() = true;
		self.edits.borrow_mut().push(TransformEdit::Delete { target: span });
	}

	pub fn insert_before(&self, anchor: SourceOffset, cursors: Vec<'a, SourceCursor<'a>>) {
		*self.changed.borrow_mut() = true;
		self.edits.borrow_mut().push(TransformEdit::InsertBefore { anchor, cursors });
	}

	pub fn insert_after(&self, anchor: SourceOffset, cursors: Vec<'a, SourceCursor<'a>>) {
		*self.changed.borrow_mut() = true;
		self.edits.borrow_mut().push(TransformEdit::InsertAfter { anchor, cursors });
	}

	pub fn replace_parsed<T>(&self, span: impl ToSpan, css: &str)
	where
		T: Parse<'a> + ToCursors,
	{
		let owned = self.bump.alloc_str(css);
		self.replace(span, self.parse_value::<T>(owned));
	}

	pub fn commit_overlays(&self) -> Result<(), CommitError> {
		let mut edits = self.edits.borrow_mut();
		if edits.is_empty() {
			return Ok(());
		}

		let mut pending_segments: Vec<'a, PendingSegment<'a>> = Vec::with_capacity_in(edits.len(), self.bump);

		for (order, edit) in edits.drain(..).enumerate() {
			match edit {
				TransformEdit::Replace { target, cursors } => {
					if target.start() > target.end() {
						return Err(CommitError::InvalidEdit { span: target });
					}
					pending_segments.push(PendingSegment {
						span: target,
						intent: OverlayKind::Replace,
						order,
						cursors,
					});
				}
				TransformEdit::InsertBefore { anchor, cursors } => {
					let span = Span::new(anchor, anchor);
					pending_segments.push(PendingSegment { span, intent: OverlayKind::InsertBefore, order, cursors });
				}
				TransformEdit::InsertAfter { anchor, cursors } => {
					let span = Span::new(anchor, anchor);
					pending_segments.push(PendingSegment { span, intent: OverlayKind::InsertAfter, order, cursors });
				}
				TransformEdit::Delete { target } => {
					pending_segments.push(PendingSegment {
						span: target,
						intent: OverlayKind::Replace,
						order,
						cursors: Vec::with_capacity_in(0, self.bump()),
					});
				}
			}
		}

		pending_segments.sort_by(|a, b| {
			a.span
				.start()
				.cmp(&b.span.start())
				.then_with(|| a.span.end().cmp(&b.span.end()))
				.then_with(|| a.intent.cmp(&b.intent))
				.then_with(|| a.order.cmp(&b.order))
		});

		let mut last_non_zero: Option<Span> = None;
		for segment in &pending_segments {
			if segment.span.start() > segment.span.end() {
				return Err(CommitError::InvalidEdit { span: segment.span });
			}
			if segment.span.start() == segment.span.end() {
				continue;
			}
			if let Some(prev) = last_non_zero
				&& segment.span.start() < prev.end()
			{
				return Err(CommitError::OverlappingEdit { previous: prev, new: segment.span });
			}
			last_non_zero = Some(segment.span);
		}

		let mut overlays = self.overlays.borrow_mut();
		overlays.clear();
		for segment in pending_segments {
			let overlay_segment = OverlaySegment::new(segment.span, segment.cursors, segment.intent);
			overlays.push_segment(overlay_segment);
		}

		Ok(())
	}
}

#[macro_export]
macro_rules! transformer {
	($(#[$meta:meta])* $vis:vis enum $feature: ident [ $metadata: ident, $($node:tt)+ ] { $( $(#[$varmeta:meta])* $variant: ident$(,)?)+ } ) => {
			use $crate::Transform;

			$(#[$meta])*
			#[bitmask(u16)]
			pub enum $feature {
				$(
					$(#[$varmeta])*
					$variant,
				)+
			}

			impl<N> $crate::TransformerFeatures<$metadata, N> for $feature
			where
				N: $($node)+ + ::css_parse::NodeWithMetadata<$metadata>
			{
				fn transforms<'a, 'ctx>(self, transformer: &'ctx $crate::Transformer<'a, $metadata, N, Self>, node: &N) {
					$(
						if $variant::may_change(transformer.features, node) {
							let mut transform = $variant::new(transformer);
							node.accept(&mut transform);
						}
					)+
				}
			}
    };
	}

#[cfg(test)]
mod tests {
	use crate::CssMinifierFeature;

	use super::*;
	use bumpalo::Bump;
	use css_ast::{CssAtomSet, CssMetadata};
	use css_parse::{ComponentValues, SourceOffset, Span};

	#[test]
	fn commit_overlays_rejects_overlapping_edits() {
		let bump = Bump::default();
		let context: Transformer<CssMetadata, ComponentValues, CssMinifierFeature> =
			Transformer::new_in(&bump, CssMinifierFeature::all_bits(), &CssAtomSet::ATOMS, "");
		let first = context.parse_value::<ComponentValues>("a");
		let second = context.parse_value::<ComponentValues>("b");

		context.replace(Span::new(SourceOffset(0), SourceOffset(2)), first);
		context.replace(Span::new(SourceOffset(1), SourceOffset(3)), second);

		let err = context.commit_overlays().expect_err("expected overlapping edits to fail");
		match err {
			CommitError::OverlappingEdit { previous, new } => {
				assert_eq!(previous, Span::new(SourceOffset(0), SourceOffset(2)));
				assert_eq!(new, Span::new(SourceOffset(1), SourceOffset(3)));
			}
			other => panic!("unexpected commit error: {other:?}"),
		}
	}

	#[test]
	fn commit_overlays_preserves_insert_order() {
		let bump = Bump::default();
		let context: Transformer<CssMetadata, ComponentValues, CssMinifierFeature> =
			Transformer::new_in(&bump, CssMinifierFeature::all_bits(), &CssAtomSet::ATOMS, "");
		let anchor = SourceOffset(5);

		context.insert_before(anchor, context.parse_value::<ComponentValues>("A"));
		context.insert_before(anchor, context.parse_value::<ComponentValues>("B"));
		context.insert_after(anchor, context.parse_value::<ComponentValues>("C"));
		context.insert_after(anchor, context.parse_value::<ComponentValues>("D"));

		context.commit_overlays().expect("commit should succeed");
		let overlays = context.overlays();
		let segments = overlays.segments();

		assert_eq!(segments.len(), 4);
		assert_eq!(segments[0].cursors()[0].source(), "A");
		assert_eq!(segments[1].cursors()[0].source(), "B");
		assert_eq!(segments[2].cursors()[0].source(), "C");
		assert_eq!(segments[3].cursors()[0].source(), "D");
	}
}