diff --git a/README.md b/README.md index 71b36b3..2490ade 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,113 @@ LightningCSS.transform_style_attribute("color: #ff0000; border: none", minify: t Having no selectors and no names, it takes neither `scope` nor `css_modules`, and says so when given one. +#### Parsing + +`parse` answers what Lightning CSS read a stylesheet as, without printing anything. It takes `filename` and `error_recovery`, the two options that change how a stylesheet is read. + +```ruby +parsed = LightningCSS.parse(".card .title:hover { color: #f00 }") + +parsed.stylesheet #=> the AST exactly as Lightning CSS serializes it +parsed.warnings #=> [] +parsed.source #=> ".card .title:hover { color: #f00 }" +``` + +The AST is Lightning CSS's own, so its shapes are the ones its [JavaScript visitors](https://lightningcss.dev/transforms.html) see and [`lightningcss/node/ast.d.ts`](https://github.com/parcel-bundler/lightningcss/blob/master/node/ast.d.ts) describes. It is what Lightning CSS understood, not what was written: `#f00` reads as an `rgb` with `r` of `255.0`, and a `0` margin reads as `0px`. + +#### Walking the AST + +`root` answers the stylesheet as a `LightningCSS::Node`, which walks, reads its fields by name, and knows what it sits inside. + +```ruby +root = LightningCSS.parse(source).root + +root.type #=> "stylesheet" +root.keys #=> the fields this node carries +root.fields #=> those fields and their values, without its type and location +root.child_nodes #=> the nodes directly under it +root.every("declaration") #=> every declaration in the stylesheet +root.each #=> an Enumerator over every node +``` + +Every object in the tree is a node, so reads chain all the way down. + +```ruby +rule = root.rules.first + +rule.type #=> "style" +rule.value.selectors.first.map(&:type) #=> ["class", "combinator", "class", "pseudo-class"] +rule.value.declarations.declarations.first.value.r +#=> 255.0 +``` + +Most objects say what they are with their own `type`. The stylesheet, a declaration block, and a declaration do not, so a node names those `"stylesheet"`, `"declaration-block"`, and `"declaration"` from where it sits. Anything else without a `type`, like the `value` a rule wraps, answers `nil`. + +Inspecting a node shows every field it carries. + +``` +#> +# rules=[]> +#> +``` + +Lightning CSS names its fields in camelCase, and a field answers to its snake_case name too. + +```ruby +block.important_declarations # the same field as block.importantDeclarations +query.media_queries # mediaQueries +``` + +Lightning CSS records where each rule starts and nothing else, so `location` is a rule's line, counted from 0, and its column, counted from 1 in UTF-16 code units. There are no end positions, so a node has no `slice` and there is no `at(offset)`. + +```ruby +rule.value.location +#=> {"source_index" => 0, "line" => 0, "column" => 1} +``` + +`ancestors` answers what a node sits inside, and `rule?` says whether it is a rule. + +```ruby +declaration = root.every("declaration").first +declaration.ancestors.find(&:rule?).type +#=> "style" +``` + +Nodes pattern match, and nest, since a field holding an object comes back as a node. Patterns take snake_case names, and a node's `type` matches even when Lightning CSS gave it none. + +```ruby +declaration => { type: "declaration", property:, value: { type: "rgb", r: } } +property #=> "color" +r #=> 255.0 +``` + +`to_h` and `to_json` answer the AST the node wraps. + +```ruby +declaration.value.to_h +#=> {"type" => "rgb", "r" => 255.0, "g" => 0.0, "b" => 0.0, "alpha" => 1.0} +``` + +`LightningCSS::Visitor` answers a node with the method named after its type, and walks through anything nothing answers. + +```ruby +class Colors < LightningCSS::Visitor + def visit_style_rule(node) + puts node.value.location["line"] + + visit_children(node) + end + + def visit_declaration(node) + puts node.property + end +end + +Colors.new.visit(LightningCSS.parse(source)) +``` + +A rule is answered as one, as in `visit_style_rule`, `visit_media_rule`, or `visit_namespace_rule`. Lightning CSS calls the rule `style`, but `namespace` and `nesting` are also parts of a selector, so `visit_namespace` answers `svg|rect` and `visit_namespace_rule` answers `@namespace`. Hyphens become underscores, so a `pseudo-class` is answered by `visit_pseudo_class`. + #### Reusing options `LightningCSS::Transformer` holds a set of options to use across many stylesheets. Options given to a call are merged over the ones it was built with. @@ -155,6 +262,8 @@ transformer.with(scope: "[s]") It answers `call` as well, so it can be handed to anything expecting something callable. +`transformer.parse` reads with the `filename` and `error_recovery` it holds and leaves the rest, which only matter to printing. + ### Options | Option | Type | Description | diff --git a/ext/lightningcss/lightningcss.c b/ext/lightningcss/lightningcss.c index abbe8f3..584bd41 100644 --- a/ext/lightningcss/lightningcss.c +++ b/ext/lightningcss/lightningcss.c @@ -90,6 +90,12 @@ static VALUE rb_transform(VALUE self, VALUE code, VALUE options) { return call(lightningcss_transform, code, options); } +static VALUE rb_parse(VALUE self, VALUE code, VALUE options) { + (void) self; + + return call(lightningcss_parse, code, options); +} + static VALUE rb_transform_style_attribute(VALUE self, VALUE code, VALUE options) { (void) self; @@ -123,6 +129,7 @@ void Init_lightningcss(void) { rb_eOptionError = rb_define_class_under(rb_mLightningCSS, "OptionError", rb_eError); rb_eBundleError = rb_define_class_under(rb_mLightningCSS, "BundleError", rb_eError); + rb_define_singleton_method(rb_mBackend, "parse", rb_parse, 2); rb_define_singleton_method(rb_mBackend, "transform", rb_transform, 2); rb_define_singleton_method(rb_mBackend, "transform_style_attribute", rb_transform_style_attribute, 2); rb_define_singleton_method(rb_mBackend, "bundle", rb_bundle, 2); diff --git a/lib/lightningcss.rb b/lib/lightningcss.rb index 97af4a7..d9734f8 100644 --- a/lib/lightningcss.rb +++ b/lib/lightningcss.rb @@ -15,6 +15,9 @@ require_relative "lightningcss/options" require_relative "lightningcss/result" +require_relative "lightningcss/node" +require_relative "lightningcss/parse_result" +require_relative "lightningcss/visitor" require_relative "lightningcss/transformer" # Ruby bindings for Lightning CSS. @@ -28,14 +31,27 @@ # LightningCSS.transform(".title { color: red }", scope: "[data-scope-abc]").code # #=> ".title[data-scope-abc]{color:red}" # +# `parse` returns the AST instead, to walk with `LightningCSS::Node` and `LightningCSS::Visitor`: +# +# LightningCSS.parse(".a { color: red }").root.every("declaration").first.property +# #=> "color" +# # `LightningCSS::Options` is what the options are read by, and `LightningCSS::Transformer` holds a # set of them to reuse across many stylesheets. +# module LightningCSS #: (String, ?filename: String?, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result def self.transform(code, **options) Result.from_json(Backend.transform(code.to_s, Options.serialize(options))) end + #: (String, ?filename: String?, ?error_recovery: bool) -> LightningCSS::ParseResult + def self.parse(code, **options) + serialized = Options.serialize(options, Options::PARSE, "a parse") + + ParseResult.from_json(Backend.parse(code.to_s, serialized), code.to_s) + end + #: (String, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result def self.bundle(path, **options) serialized = Options.serialize(options, Options::BUNDLE, "a bundle") diff --git a/lib/lightningcss/backend.rb b/lib/lightningcss/backend.rb index 270d909..fcb4fdb 100644 --- a/lib/lightningcss/backend.rb +++ b/lib/lightningcss/backend.rb @@ -8,6 +8,11 @@ def transform(_code, _options_json) unavailable(__method__) end + #: (String, String) -> String + def parse(_code, _options_json) + unavailable(__method__) + end + #: (String, String) -> String def transform_style_attribute(_code, _options_json) unavailable(__method__) diff --git a/lib/lightningcss/node.rb b/lib/lightningcss/node.rb new file mode 100644 index 0000000..d32a663 --- /dev/null +++ b/lib/lightningcss/node.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +module LightningCSS + # One object in the AST Lightning CSS parsed a stylesheet to. + # + # root = LightningCSS.parse(".a { color: red }").root + # + # root.type #=> "stylesheet" + # root.every("declaration") #=> every declaration in the stylesheet + # root.rules.first.value.location #=> {"source_index" => 0, "line" => 0, "column" => 1} + # + # Every object in the tree is a node, so reads chain all the way down. Most say what they are + # with their own `type`. The stylesheet, a declaration block, and a declaration do not, so a node + # names those from where it sits. + # + class Node + include Enumerable #[LightningCSS::Node] + + POSITION = ["type", "loc"].freeze #: Array[String] + DECLARATIONS = ["declarations", "importantDeclarations"].freeze #: Array[String] + + attr_reader :parent #: LightningCSS::Node? + attr_reader :field #: String? + + #: (Hash[String, untyped], ?LightningCSS::Node?, ?String?) -> void + def initialize(attributes, parent = nil, field = nil) + @attributes = attributes + @parent = parent + @field = field + end + + #: () -> String? + def type + attributes.fetch("type") { inferred_type } + end + + #: () -> String? + def underscored_type + return nil unless (name = type) + + name = name.tr("-", "_").downcase + + rule? ? "#{name}_rule" : name + end + + #: () -> bool + def rule? + field == "rules" && attributes.key?("type") + end + + #: () -> Hash[String, Integer]? + def location + attributes["loc"] + end + + #: (String) -> untyped + def [](key) + attributes[key] + end + + #: () -> Array[String] + def keys + attributes.keys + end + + #: () -> Hash[String, untyped] + def to_h + attributes + end + + #: (?untyped) -> String + def to_json(state = nil) + state ? attributes.to_json(state) : attributes.to_json + end + + #: (Array[Symbol]?) -> Hash[Symbol, untyped] + def deconstruct_keys(keys) + found = {} #: Hash[Symbol, untyped] + + if keys + keys.each do |name| + key = field_for(name.to_s) + + if key + found[name] = wrap(attributes[key], key) + elsif name == :type && type + found[name] = type + end + end + else + found[:type] = type if type + attributes.each_key { |key| found[key.to_sym] = wrap(attributes[key], key) } + end + + found + end + + #: () -> Array[LightningCSS::Node] + def child_nodes + @child_nodes ||= attributes.flat_map { |key, value| key == "loc" ? [] : nodes_in(value, key) }.freeze + end + + #: () { (LightningCSS::Node) -> void } -> void + #: () -> Enumerator[LightningCSS::Node, void] + def each(&) + return enum_for(:each) unless block_given? + + yield self + + child_nodes.each { |child| child.each(&) } + end + + #: () -> Array[LightningCSS::Node] + def ancestors + parent ? [parent, *parent.ancestors] : [] + end + + #: (String) -> Array[LightningCSS::Node] + def every(type) + each.select { |node| node.type == type || node.underscored_type == type } + end + + #: () -> Hash[String, untyped] + def fields + attributes.except(*POSITION) + end + + #: () -> String + def inspect + described = fields.map { |key, value| described_field(key, value) } + position = location + described.unshift("location=#{position["line"]}:#{position["column"]}") if position + + "#<#{self.class.name}#{" #{type}" if type}#{" #{described.join(" ")}" unless described.empty?}>" + end + + #: (Symbol, *untyped) -> untyped + def method_missing(name, *arguments) + key = field_for(name.to_s) + + return super unless key + + wrap(attributes[key], key) + end + + #: (Symbol, ?bool) -> bool + def respond_to_missing?(name, include_private = false) + !field_for(name.to_s).nil? || super + end + + protected + + attr_reader :attributes #: Hash[String, untyped] + + private + + #: () -> String? + def inferred_type + if parent.nil? && attributes.key?("rules") && attributes.key?("sources") + "stylesheet" + elsif attributes.key?("importantDeclarations") + "declaration-block" + elsif DECLARATIONS.include?(field) && attributes.key?("property") + "declaration" + end + end + + #: (untyped, String) -> Array[LightningCSS::Node] + def nodes_in(value, key) + case value + when Hash + [Node.new(value, self, key)] + when Array + value.flat_map { |item| nodes_in(item, key) } + else + [] + end + end + + #: (String, untyped) -> String + def described_field(key, value) + case value + when Array + "#{key}=#{value.empty? ? "[]" : "[... #{counted(value)}]"}" + when Hash + type = Node.new(value, self, key).type + + "#{key}=#<#{self.class.name}#{" #{type}" if type}>" + else + "#{key}=#{value.inspect}" + end + end + + #: (Array[untyped]) -> String + def counted(items) + "#{items.length} #{items.length == 1 ? "item" : "items"}" + end + + #: (String) -> String? + def field_for(name) + return name if attributes.key?(name) + + camelized = name.gsub(/_([a-z\d])/) { Regexp.last_match(1).to_s.upcase } + + camelized if attributes.key?(camelized) + end + + #: (untyped, String) -> untyped + def wrap(value, key) + case value + when Hash + key == "loc" ? value : Node.new(value, self, key) + when Array + value.map { |item| wrap(item, key) } + else + value + end + end + end +end diff --git a/lib/lightningcss/options.rb b/lib/lightningcss/options.rb index f703287..91533b8 100644 --- a/lib/lightningcss/options.rb +++ b/lib/lightningcss/options.rb @@ -26,6 +26,11 @@ class Options :targets ].freeze #: Array[Symbol] + PARSE = [ + :filename, + :error_recovery + ].freeze #: Array[Symbol] + BUNDLE = [ :minify, :error_recovery, diff --git a/lib/lightningcss/parse_result.rb b/lib/lightningcss/parse_result.rb new file mode 100644 index 0000000..46124a6 --- /dev/null +++ b/lib/lightningcss/parse_result.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module LightningCSS + # What a stylesheet parsed to. + # + # `stylesheet` is the AST exactly as Lightning CSS serializes it, and `root` is the same tree as a + # `LightningCSS::Node` to walk. `warnings` are the ones a transform would have reported. + # + class ParseResult + attr_reader :source #: String? + attr_reader :stylesheet #: Hash[String, untyped] + attr_reader :warnings #: Array[String] + + #: (String, ?String?) -> LightningCSS::ParseResult + def self.from_json(payload, source = nil) + parsed = JSON.parse(payload) + + new( + source: source, + stylesheet: parsed.fetch("stylesheet"), + warnings: parsed.fetch("warnings") + ) + end + + #: (stylesheet: Hash[String, untyped], warnings: Array[String], ?source: String?) -> void + def initialize(stylesheet:, warnings:, source: nil) + @source = source + @stylesheet = stylesheet.freeze + @warnings = warnings.freeze + + freeze + end + + #: () -> LightningCSS::Node + def root + Node.new(stylesheet) + end + + #: () -> bool + def warnings? + !warnings.empty? + end + + #: () -> String + def inspect + parts = ["rules=#{stylesheet.fetch("rules").length}"] + parts << "warnings=#{warnings.inspect}" if warnings? + + "#<#{self.class.name} #{parts.join(" ")}>" + end + end +end diff --git a/lib/lightningcss/transformer.rb b/lib/lightningcss/transformer.rb index e26e8ec..3363381 100644 --- a/lib/lightningcss/transformer.rb +++ b/lib/lightningcss/transformer.rb @@ -28,6 +28,11 @@ def transform(code, **overrides) alias call transform + #: (String, ?filename: String?, ?error_recovery: bool) -> LightningCSS::ParseResult + def parse(code, **overrides) + LightningCSS.parse(code, **options.slice(*Options::PARSE), **overrides) + end + #: (String, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result def bundle(path, **overrides) LightningCSS.bundle(path, **options, **overrides) diff --git a/lib/lightningcss/visitor.rb b/lib/lightningcss/visitor.rb new file mode 100644 index 0000000..6719539 --- /dev/null +++ b/lib/lightningcss/visitor.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module LightningCSS + # Walks an AST, answering each node with the method named after it and walking through anything + # nothing answers. + # + # class Colors < LightningCSS::Visitor + # def visit_declaration(node) + # puts node.value.type if node.property == "color" + # end + # end + # + # Colors.new.visit(LightningCSS.parse(".a { color: red }")) + # + # A rule is answered as one, as in `visit_style_rule` or `visit_media_rule`, since `style`, + # `nesting`, and `namespace` are also what a selector is made of. + # + class Visitor + #: (LightningCSS::Node | LightningCSS::ParseResult) -> void + def visit(node) + node = node.root if node.is_a?(ParseResult) + + method_name = "visit_#{node.underscored_type}" if node.underscored_type + + method_name && respond_to?(method_name) ? public_send(method_name, node) : visit_children(node) + + nil + end + + #: (LightningCSS::Node) -> void + def visit_children(node) + node.child_nodes.each { |child| visit(child) } + + nil + end + end +end diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3d5bb40..a1851b1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -90,6 +90,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -245,6 +248,7 @@ dependencies = [ "dtoa-short", "itoa", "phf 0.13.1", + "serde", "smallvec", ] @@ -511,6 +515,7 @@ dependencies = [ "serde", "serde-content", "smallvec", + "static-self", ] [[package]] @@ -599,7 +604,9 @@ dependencies = [ "phf_codegen", "precomputed-hash", "rustc-hash", + "serde", "smallvec", + "static-self", ] [[package]] @@ -1002,6 +1009,31 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "static-self" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6635404b73efc136af3a7956e53c53d4f34b2f16c95a15c438929add0f69412" +dependencies = [ + "indexmap", + "smallvec", + "static-self-derive", +] + +[[package]] +name = "static-self-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5268c96d4b907c558a9a52d8492522d6c7b559651a5e1d8f2d551e461b9425d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] name = "strsim" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f71eac6..ba7a92a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,7 +15,7 @@ path = "src/lib.rs" crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -lightningcss = { version = "=1.0.0-alpha.72", features = ["visitor"] } +lightningcss = { version = "=1.0.0-alpha.72", features = ["visitor", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cfd002b..ba731c4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -21,7 +21,7 @@ use lightningcss::bundler::{BundleErrorKind, Bundler, FileProvider}; use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleAttribute, StyleSheet}; use lightningcss::visitor::Visit; -use crate::options::{TransformOptions, TransformResult}; +use crate::options::{ParseResult, TransformOptions, TransformResult}; use crate::scope::Scoper; pub const VERSION: &str = env!("GEM_VERSION"); @@ -125,6 +125,26 @@ unsafe fn borrow_options(pointer: *const c_char) -> Result(code: &'i str, options: &TransformOptions) -> Result>, Failure> { + let collected = Arc::new(RwLock::new(Vec::new())); + + let parser_options = ParserOptions { + filename: options.filename.clone().unwrap_or_default(), + error_recovery: options.error_recovery, + warnings: Some(collected.clone()), + ..ParserOptions::default() + }; + + let stylesheet = StyleSheet::parse(code, parser_options).map_err(|error| Failure::parse(error.to_string()))?; + + let warnings = collected + .read() + .map(|warnings| warnings.iter().map(|warning| warning.to_string()).collect()) + .unwrap_or_default(); + + Ok(ParseResult { stylesheet, warnings }) +} + fn transform_source(code: &str, options: &TransformOptions) -> Result { let filename = options.filename.clone().unwrap_or_default(); let css_modules = match &options.css_modules { @@ -295,7 +315,7 @@ fn transform_attribute(code: &str, options: &TransformOptions) -> Result) -> LightningCssResult { +fn to_result(outcome: Result) -> LightningCssResult { match outcome { Ok(result) => match serde_json::to_string(&result) { Ok(json) => LightningCssResult::ok(json), @@ -323,6 +343,21 @@ pub unsafe extern "C" fn lightningcss_transform( to_result(transform_source(code, &options)) } +#[no_mangle] +pub unsafe extern "C" fn lightningcss_parse(code: *const c_char, options_json: *const c_char) -> LightningCssResult { + let code = match borrow_str(code, "code") { + Ok(code) => code, + Err(failure) => return LightningCssResult::err(failure), + }; + + let options = match borrow_options(options_json) { + Ok(options) => options, + Err(failure) => return LightningCssResult::err(failure), + }; + + to_result(parse_source(code, &options)) +} + #[no_mangle] pub unsafe extern "C" fn lightningcss_transform_style_attribute( code: *const c_char, diff --git a/rust/src/options.rs b/rust/src/options.rs index 5a807ec..53e01b1 100644 --- a/rust/src/options.rs +++ b/rust/src/options.rs @@ -89,6 +89,12 @@ impl TransformOptions { } } +#[derive(Debug, Serialize)] +pub struct ParseResult { + pub stylesheet: T, + pub warnings: Vec, +} + #[derive(Debug, Serialize)] pub struct TransformResult { pub code: String, diff --git a/rust/tests/parse_test.rs b/rust/tests/parse_test.rs new file mode 100644 index 0000000..d641a37 --- /dev/null +++ b/rust/tests/parse_test.rs @@ -0,0 +1,62 @@ +use lightningcss_ffi::{lightningcss_parse, lightningcss_result_free, LightningCssErrorCode, LightningCssResult}; + +use std::ffi::{CStr, CString}; + +fn parse(code: &str, options: &str) -> LightningCssResult { + let code = CString::new(code).unwrap(); + let options = CString::new(options).unwrap(); + + unsafe { lightningcss_parse(code.as_ptr(), options.as_ptr()) } +} + +fn answer(code: &str, options: &str) -> Result { + let result = parse(code, options); + + let outcome = if result.error.is_null() { + let json = unsafe { CStr::from_ptr(result.value) }.to_str().unwrap(); + + Ok(serde_json::from_str(json).unwrap()) + } else { + Err(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap().to_string()) + }; + + unsafe { lightningcss_result_free(result) }; + + outcome +} + +#[test] +fn answers_the_stylesheet_as_lightning_css_serializes_it() { + let parsed = answer(".a { color: red }", "{}").unwrap(); + + assert_eq!(parsed["stylesheet"]["rules"][0]["type"], "style"); + assert_eq!( + parsed["stylesheet"]["rules"][0]["value"]["declarations"]["declarations"][0]["property"], + "color" + ); + assert_eq!(parsed["warnings"], serde_json::json!([])); +} + +#[test] +fn names_the_file_it_read() { + let parsed = answer(".a {}", r#"{"filename":"a.css"}"#).unwrap(); + + assert_eq!(parsed["stylesheet"]["sources"], serde_json::json!(["a.css"])); +} + +#[test] +fn collects_warnings() { + let parsed = answer(".a:deep(.b) {}", "{}").unwrap(); + + assert_eq!(parsed["warnings"].as_array().unwrap().len(), 1); +} + +#[test] +fn a_stylesheet_it_cannot_read_carries_the_parse_code() { + let result = parse(". {}", "{}"); + let code = result.code; + + unsafe { lightningcss_result_free(result) }; + + assert_eq!(code, LightningCssErrorCode::Parse); +} diff --git a/sig/lightningcss.rbs b/sig/lightningcss.rbs index 88e74bd..4eab55b 100644 --- a/sig/lightningcss.rbs +++ b/sig/lightningcss.rbs @@ -11,12 +11,20 @@ # LightningCSS.transform(".title { color: red }", scope: "[data-scope-abc]").code # #=> ".title[data-scope-abc]{color:red}" # +# `parse` returns the AST instead, to walk with `LightningCSS::Node` and `LightningCSS::Visitor`: +# +# LightningCSS.parse(".a { color: red }").root.every("declaration").first.property +# #=> "color" +# # `LightningCSS::Options` is what the options are read by, and `LightningCSS::Transformer` holds a # set of them to reuse across many stylesheets. module LightningCSS # : (String, ?filename: String?, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result def self.transform: (String, ?filename: String?, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result + # : (String, ?filename: String?, ?error_recovery: bool) -> LightningCSS::ParseResult + def self.parse: (String, ?filename: String?, ?error_recovery: bool) -> LightningCSS::ParseResult + # : (String, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result def self.bundle: (String, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result diff --git a/sig/lightningcss/backend.rbs b/sig/lightningcss/backend.rbs index 7c976bd..6276323 100644 --- a/sig/lightningcss/backend.rbs +++ b/sig/lightningcss/backend.rbs @@ -6,6 +6,9 @@ module LightningCSS # : (String, String) -> String def transform: (String, String) -> String + # : (String, String) -> String + def parse: (String, String) -> String + # : (String, String) -> String def transform_style_attribute: (String, String) -> String diff --git a/sig/lightningcss/node.rbs b/sig/lightningcss/node.rbs new file mode 100644 index 0000000..449e963 --- /dev/null +++ b/sig/lightningcss/node.rbs @@ -0,0 +1,104 @@ +# Generated from lib/lightningcss/node.rb with RBS::Inline + +module LightningCSS + # One object in the AST Lightning CSS parsed a stylesheet to. + # + # root = LightningCSS.parse(".a { color: red }").root + # + # root.type #=> "stylesheet" + # root.every("declaration") #=> every declaration in the stylesheet + # root.rules.first.value.location #=> {"source_index" => 0, "line" => 0, "column" => 1} + # + # Every object in the tree is a node, so reads chain all the way down. Most say what they are + # with their own `type`. The stylesheet, a declaration block, and a declaration do not, so a node + # names those from where it sits. + class Node + include Enumerable[LightningCSS::Node] + + POSITION: Array[String] + + DECLARATIONS: Array[String] + + attr_reader parent: LightningCSS::Node? + + attr_reader field: String? + + # : (Hash[String, untyped], ?LightningCSS::Node?, ?String?) -> void + def initialize: (Hash[String, untyped], ?LightningCSS::Node?, ?String?) -> void + + # : () -> String? + def type: () -> String? + + # : () -> String? + def underscored_type: () -> String? + + # : () -> bool + def rule?: () -> bool + + # : () -> Hash[String, Integer]? + def location: () -> Hash[String, Integer]? + + # : (String) -> untyped + def []: (String) -> untyped + + # : () -> Array[String] + def keys: () -> Array[String] + + # : () -> Hash[String, untyped] + def to_h: () -> Hash[String, untyped] + + # : (?untyped) -> String + def to_json: (?untyped) -> String + + # : (Array[Symbol]?) -> Hash[Symbol, untyped] + def deconstruct_keys: (Array[Symbol]?) -> Hash[Symbol, untyped] + + # : () -> Array[LightningCSS::Node] + def child_nodes: () -> Array[LightningCSS::Node] + + # : () { (LightningCSS::Node) -> void } -> void + # : () -> Enumerator[LightningCSS::Node, void] + def each: () { (LightningCSS::Node) -> void } -> void + | () -> Enumerator[LightningCSS::Node, void] + + # : () -> Array[LightningCSS::Node] + def ancestors: () -> Array[LightningCSS::Node] + + # : (String) -> Array[LightningCSS::Node] + def every: (String) -> Array[LightningCSS::Node] + + # : () -> Hash[String, untyped] + def fields: () -> Hash[String, untyped] + + # : () -> String + def inspect: () -> String + + # : (Symbol, *untyped) -> untyped + def method_missing: (Symbol, *untyped) -> untyped + + # : (Symbol, ?bool) -> bool + def respond_to_missing?: (Symbol, ?bool) -> bool + + attr_reader attributes: Hash[String, untyped] + + private + + # : () -> String? + def inferred_type: () -> String? + + # : (untyped, String) -> Array[LightningCSS::Node] + def nodes_in: (untyped, String) -> Array[LightningCSS::Node] + + # : (String, untyped) -> String + def described_field: (String, untyped) -> String + + # : (Array[untyped]) -> String + def counted: (Array[untyped]) -> String + + # : (String) -> String? + def field_for: (String) -> String? + + # : (untyped, String) -> untyped + def wrap: (untyped, String) -> untyped + end +end diff --git a/sig/lightningcss/options.rbs b/sig/lightningcss/options.rbs index 56189f8..e472cdb 100644 --- a/sig/lightningcss/options.rbs +++ b/sig/lightningcss/options.rbs @@ -13,6 +13,8 @@ module LightningCSS STYLE_ATTRIBUTE: Array[Symbol] + PARSE: Array[Symbol] + BUNDLE: Array[Symbol] attr_reader to_h: Hash[Symbol, untyped] diff --git a/sig/lightningcss/parse_result.rbs b/sig/lightningcss/parse_result.rbs new file mode 100644 index 0000000..49c499b --- /dev/null +++ b/sig/lightningcss/parse_result.rbs @@ -0,0 +1,30 @@ +# Generated from lib/lightningcss/parse_result.rb with RBS::Inline + +module LightningCSS + # What a stylesheet parsed to. + # + # `stylesheet` is the AST exactly as Lightning CSS serializes it, and `root` is the same tree as a + # `LightningCSS::Node` to walk. `warnings` are the ones a transform would have reported. + class ParseResult + attr_reader source: String? + + attr_reader stylesheet: Hash[String, untyped] + + attr_reader warnings: Array[String] + + # : (String, ?String?) -> LightningCSS::ParseResult + def self.from_json: (String, ?String?) -> LightningCSS::ParseResult + + # : (stylesheet: Hash[String, untyped], warnings: Array[String], ?source: String?) -> void + def initialize: (stylesheet: Hash[String, untyped], warnings: Array[String], ?source: String?) -> void + + # : () -> LightningCSS::Node + def root: () -> LightningCSS::Node + + # : () -> bool + def warnings?: () -> bool + + # : () -> String + def inspect: () -> String + end +end diff --git a/sig/lightningcss/transformer.rbs b/sig/lightningcss/transformer.rbs index 0fe2117..ddab338 100644 --- a/sig/lightningcss/transformer.rbs +++ b/sig/lightningcss/transformer.rbs @@ -21,6 +21,9 @@ module LightningCSS alias call transform + # : (String, ?filename: String?, ?error_recovery: bool) -> LightningCSS::ParseResult + def parse: (String, ?filename: String?, ?error_recovery: bool) -> LightningCSS::ParseResult + # : (String, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result def bundle: (String, ?minify: bool, ?error_recovery: bool, ?targets: browsers?, ?css_modules: css_modules?, ?scope: String?) -> LightningCSS::Result diff --git a/sig/lightningcss/visitor.rbs b/sig/lightningcss/visitor.rbs new file mode 100644 index 0000000..6bc1e1e --- /dev/null +++ b/sig/lightningcss/visitor.rbs @@ -0,0 +1,24 @@ +# Generated from lib/lightningcss/visitor.rb with RBS::Inline + +module LightningCSS + # Walks an AST, answering each node with the method named after it and walking through anything + # nothing answers. + # + # class Colors < LightningCSS::Visitor + # def visit_declaration(node) + # puts node.value.type if node.property == "color" + # end + # end + # + # Colors.new.visit(LightningCSS.parse(".a { color: red }")) + # + # A rule is answered as one, as in `visit_style_rule` or `visit_media_rule`, since `style`, + # `nesting`, and `namespace` are also what a selector is made of. + class Visitor + # : (LightningCSS::Node | LightningCSS::ParseResult) -> void + def visit: (LightningCSS::Node | LightningCSS::ParseResult) -> void + + # : (LightningCSS::Node) -> void + def visit_children: (LightningCSS::Node) -> void + end +end diff --git a/test/lightningcss/node_test.rb b/test/lightningcss/node_test.rb new file mode 100644 index 0000000..e345e96 --- /dev/null +++ b/test/lightningcss/node_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "test_helper" + +module LightningCSS + class NodeTest < Minitest::Spec + SOURCE = ".card .title:hover { color: #f00; margin: 0 auto }\n@media (min-width: 600px) { .a { --x: 1px } }" + + def root + LightningCSS.parse(SOURCE).root + end + + test "wraps the stylesheet" do + assert_equal "stylesheet", root.type + assert_nil root.parent + end + + test "names a rule the way a visitor answers it" do + rule = root.rules.first + + assert_equal "style", rule.type + assert_predicate rule, :rule? + assert_equal "style_rule", rule.underscored_type + assert_equal "media_rule", root.rules.last.underscored_type + end + + test "names everything else after its type" do + pseudo = root.every("pseudo-class").first + + refute_predicate pseudo, :rule? + assert_equal "pseudo_class", pseudo.underscored_type + end + + test "names the nodes Lightning CSS gives no type from where they sit" do + block = root.rules.first.value.declarations + + assert_equal "declaration-block", block.type + assert_equal "declaration", block.declarations.first.type + end + + test "has no type when neither it nor where it sits says one" do + assert_nil root.rules.first.value.type + assert_nil root.rules.first.value.underscored_type + end + + test "walks everything under it" do + types = root.each.map(&:type) + + assert_equal "stylesheet", types.first + assert_includes types, "rgb" + end + + test "answers every node of one type, by its type or its visitor name" do + assert_equal(["card", "title", "a"], root.every("class").map(&:name)) + assert_equal(["color", "margin", "custom"], root.every("declaration").map(&:property)) + assert_equal 2, root.every("style_rule").length + end + + test "walks into selectors, which are lists of lists" do + assert_equal ["class", "combinator", "class", "pseudo-class"], root.rules.first.value.selectors.first.map(&:type) + end + + test "reads a field as the node it holds" do + color = root.every("declaration").first + + assert_equal "rgb", color.value.type + assert_in_delta 255.0, color.value.r + assert_equal "color", color["property"] + end + + test "reads a camelCase field by its snake_case name" do + block = root.rules.first.value.declarations + + assert_empty block.important_declarations + assert_respond_to block, :important_declarations + assert_equal ["all"], root.rules.last.value.query.media_queries.map(&:media_type) + end + + test "raises for a field it does not have" do + assert_raises(NoMethodError) { root.nonsense } + refute_respond_to root, :nonsense + end + + test "answers where a rule starts as Lightning CSS reports it" do + assert_equal({ "source_index" => 0, "line" => 1, "column" => 29 }, root.rules.last.value.rules.first.value.location) + assert_nil root.location + end + + test "does not walk into a location" do + refute(root.each.any? { |node| node.keys.include?("source_index") }) + end + + test "knows what it sits inside" do + declaration = root.every("declaration").last + + assert_equal ["declaration-block", nil, "style", nil, "media", "stylesheet"], declaration.ancestors.map(&:type) + assert_equal "declarations", declaration.field + end + + test "answers its fields without its type or location" do + assert_equal ["selectors", "declarations", "rules"], root.rules.first.value.fields.keys + end + + test "pattern matches, nesting through the nodes it holds" do + matched = root.every("declaration").find do |node| + node in { type: "declaration", property: "color", value: { type: "rgb", r: 255.0 } } + end + + refute_nil matched + end + + test "pattern matches by snake_case name" do + root.rules.first.value.declarations => { important_declarations: } + + assert_empty important_declarations + end + + test "answers its type in a pattern with no keys" do + assert_equal "declaration", root.every("declaration").first.deconstruct_keys(nil)[:type] + end + + test "answers the AST it wraps" do + color = root.every("rgb").first + + assert_equal({ "type" => "rgb", "r" => 255.0, "g" => 0.0, "b" => 0.0, "alpha" => 1.0 }, color.to_h) + assert_equal color.to_h.to_json, color.to_json + assert_equal color.to_h.to_json, JSON.generate(color) + end + + test "prints every field it carries" do + assert_equal "#>", root.rules.first.inspect + assert_equal "# rules=[]>", root.rules.first.value.inspect + assert_equal "#>", root.every("declaration").first.inspect + end + end +end diff --git a/test/lightningcss/parse_test.rb b/test/lightningcss/parse_test.rb new file mode 100644 index 0000000..7edd503 --- /dev/null +++ b/test/lightningcss/parse_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "test_helper" + +module LightningCSS + class ParseTest < Minitest::Spec + SOURCE = ".a { color: red }" + + test "answers the stylesheet as Lightning CSS serializes it" do + result = LightningCSS.parse(SOURCE) + + assert_equal ["rules", "sources", "sourceMapUrls", "licenseComments"], result.stylesheet.keys + assert_equal "style", result.stylesheet.dig("rules", 0, "type") + end + + test "keeps the source it read" do + assert_equal SOURCE, LightningCSS.parse(SOURCE).source + end + + test "answers the stylesheet as a node" do + root = LightningCSS.parse(SOURCE).root + + assert_instance_of Node, root + assert_equal "stylesheet", root.type + end + + test "names the file it read" do + assert_equal ["a.css"], LightningCSS.parse(SOURCE, filename: "a.css").root.sources + end + + test "collects warnings" do + result = LightningCSS.parse(".a:deep(.b) { color: red }") + + assert_predicate result, :warnings? + assert_match "'deep' is not recognized as a valid pseudo-class", result.warnings.first + end + + test "raises on a stylesheet it cannot read" do + error = assert_raises(LightningCSS::ParseError) { LightningCSS.parse(". { color: red }") } + + assert_equal 'Expected identifier in class selector, got WhiteSpace(" ") at :0:2', error.message + end + + test "recovers when asked to" do + result = LightningCSS.parse(".a { color: red } @media (min-width: ) { .b { color: blue } } .c { color: green }", error_recovery: true) + + assert_predicate result, :warnings? + end + + test "refuses options that only matter to printing" do + error = assert_raises(LightningCSS::OptionError) { LightningCSS.parse(SOURCE, minify: true) } + + assert_equal "minify is not an option for a parse", error.message + end + + test "prints what it parsed" do + assert_equal "#", LightningCSS.parse(SOURCE).inspect + end + + test "is frozen" do + result = LightningCSS.parse(SOURCE) + + assert_predicate result, :frozen? + assert_predicate result.stylesheet, :frozen? + end + + test "is reachable from a transformer, which keeps only the options a parse reads" do + transformer = LightningCSS::Transformer.new(minify: true, filename: "a.css") + + assert_equal ["a.css"], transformer.parse(SOURCE).root.sources + end + end +end diff --git a/test/lightningcss/visitor_test.rb b/test/lightningcss/visitor_test.rb new file mode 100644 index 0000000..a980bc8 --- /dev/null +++ b/test/lightningcss/visitor_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "test_helper" + +module LightningCSS + class VisitorTest < Minitest::Spec + SOURCE = ".a { color: red }\n@media print { .b { color: blue; &.c { margin: 0 } } }" + + class Collector < Visitor + attr_reader :seen #: Array[String] + + def initialize + @seen = [] + + super + end + + def visit_media_rule(node) + seen << "media" + + visit_children(node) + end + + def visit_declaration(node) + seen << node.property + end + + def visit_nesting(_node) + seen << "&" + end + end + + test "answers a node with the method named after it" do + collector = Collector.new + collector.visit(LightningCSS.parse(SOURCE).root) + + assert_equal ["color", "media", "color", "&", "margin"], collector.seen + end + + test "takes a parse result and walks from its root" do + collector = Collector.new + collector.visit(LightningCSS.parse(SOURCE)) + + assert_equal ["color", "media", "color", "&", "margin"], collector.seen + end + + test "answers a rule as a rule and a selector as a selector" do + seen = [] + + visitor = Class.new(Visitor) do + define_method(:visit_namespace_rule) { |node| seen << "rule #{node.value.prefix}" } + define_method(:visit_namespace) { |node| seen << "selector #{node.prefix}" } + end + + visitor.new.visit(LightningCSS.parse("@namespace svg url(http://www.w3.org/2000/svg);\nsvg|rect { color: red }")) + + assert_equal ["rule svg", "selector svg"], seen + end + + test "does not walk under a node it answers unless asked to" do + seen = [] + + visitor = Class.new(Visitor) do + define_method(:visit_media_rule) { |_node| seen << "media" } + define_method(:visit_declaration) { |node| seen << node.property } + end + + visitor.new.visit(LightningCSS.parse(SOURCE)) + + assert_equal ["color", "media"], seen + end + + test "answers nothing" do + assert_nil Collector.new.visit(LightningCSS.parse(SOURCE)) + end + end +end