From 633a33aa56f77484fa3b851e795899e60f5ba345 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Wed, 14 Oct 2026 15:39:26 +0900 Subject: [PATCH] Expose error and warning locations as data --- README.md | 31 +++++++++++++- ext/lightningcss/lightningcss.c | 4 +- lib/lightningcss.rb | 2 + lib/lightningcss/errors.rb | 23 ++++++++++- lib/lightningcss/location.rb | 21 ++++++++++ lib/lightningcss/parse_result.rb | 8 ++-- lib/lightningcss/result.rb | 8 ++-- lib/lightningcss/warning.rb | 60 +++++++++++++++++++++++++++ rust/src/lib.rs | 49 ++++++++++------------ rust/src/options.rs | 66 +++++++++++++++++++++++++++++- rust/tests/bundle_test.rs | 8 +++- rust/tests/parse_test.rs | 8 +++- rust/tests/transform_test.rs | 34 ++++++++++++++- sig/lightningcss/errors.rbs | 9 ++++ sig/lightningcss/location.rbs | 26 ++++++++++++ sig/lightningcss/parse_result.rbs | 6 +-- sig/lightningcss/result.rbs | 6 +-- sig/lightningcss/warning.rbs | 36 ++++++++++++++++ test/lightningcss/bundle_test.rb | 20 +++++++++ test/lightningcss/errors_test.rb | 28 +++++++++++++ test/lightningcss/location_test.rb | 23 +++++++++++ test/lightningcss/warnings_test.rb | 42 +++++++++++++++++++ 22 files changed, 466 insertions(+), 52 deletions(-) create mode 100644 lib/lightningcss/location.rb create mode 100644 lib/lightningcss/warning.rb create mode 100644 sig/lightningcss/location.rbs create mode 100644 sig/lightningcss/warning.rbs create mode 100644 test/lightningcss/location_test.rb diff --git a/README.md b/README.md index 4b6e7be..ad12170 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ It answers `call` as well, so it can be handed to anything expecting something c | `filename` | `String` | The name to use in errors and warnings. | | `minify` | `bool` | Whether to print the result as small as it goes. | | `error_recovery` | `bool` | Whether to carry on past a rule it cannot read. Off by default, so such a rule raises. | -| `targets` | `Hash` | The browsers being compiled for, each as its major version. | +| `targets` | `Hash` | The browsers being compiled for, each with the oldest version supported. | | `css_modules` | `bool`, `Hash` | Whether to compile as a CSS module, and how. | | `scope` | `String` | A selector fragment to narrow every rule by. | @@ -337,13 +337,40 @@ result = LightningCSS.transform(".a:deep(.b) { color: red }", minify: true) result.warnings? #=> true -result.warnings.first +result.warnings.first.to_s #=> "'deep' is not recognized as a valid pseudo-class. Did you mean '::deep' (pseudo-element) or is this a typo? at :1:9" result.code #=> ".a:deep(.b){color:red}" ``` +### Locations + +A warning is a `LightningCSS::Warning` with a `message`, a `type`, and a `location`, the same as a diagnostic in Prism or Herb. A `LightningCSS::Error` carries the same `type` and `location`, and keeps the location in its message. + +A `LightningCSS::Location` counts lines from 1 and columns from 0, the same as Prism, Herb, and Yerba, and prints the way an editor reads it: + +```ruby +css = ".a { color: red; }\n.b, { color: blue; }\n" + +begin + LightningCSS.transform(css, filename: "main.css") +rescue LightningCSS::ParseError => error + error.message #=> "Invalid empty selector at main.css:2:5" + error.type #=> "SelectorError" + error.location #=> # +end + +warning = LightningCSS.transform(css, filename: "main.css", error_recovery: true).warnings.first + +warning.message #=> "Invalid empty selector" +warning.type #=> "SelectorError" +warning.location.line #=> 2 +warning.to_s #=> "Invalid empty selector at main.css:2:5" +``` + +A warning converts to the string it always was wherever a `String` is expected and is equal to it, so `warnings.include?("...")` still works. An error without a location, such as an `OptionError`, has `nil` for `type` and `location`. + ### Development The gem is a C extension over a Rust crate. `rust/` builds a static library and generates the C header with [cbindgen](https://github.com/mozilla/cbindgen), `ext/lightningcss/` wraps it, and `lib/` is the Ruby API over that. diff --git a/ext/lightningcss/lightningcss.c b/ext/lightningcss/lightningcss.c index 584bd41..402736a 100644 --- a/ext/lightningcss/lightningcss.c +++ b/ext/lightningcss/lightningcss.c @@ -43,12 +43,12 @@ static VALUE error_class_for(enum LightningCssErrorCode code) { static VALUE unwrap(struct LightningCssResult result) { if (result.error) { - VALUE message = make_utf8_string(result.error); + VALUE payload = make_utf8_string(result.error); VALUE error_class = error_class_for(result.code); lightningcss_result_free(result); - rb_raise(error_class, "%s", StringValueCStr(message)); + rb_exc_raise(rb_funcall(error_class, rb_intern("from_json"), 1, payload)); } if (!result.value) { diff --git a/lib/lightningcss.rb b/lib/lightningcss.rb index d9734f8..f07274a 100644 --- a/lib/lightningcss.rb +++ b/lib/lightningcss.rb @@ -3,6 +3,7 @@ require "json" require_relative "lightningcss/version" +require_relative "lightningcss/location" require_relative "lightningcss/errors" require_relative "lightningcss/backend" @@ -14,6 +15,7 @@ end require_relative "lightningcss/options" +require_relative "lightningcss/warning" require_relative "lightningcss/result" require_relative "lightningcss/node" require_relative "lightningcss/parse_result" diff --git a/lib/lightningcss/errors.rb b/lib/lightningcss/errors.rb index c11f0a9..35b213d 100644 --- a/lib/lightningcss/errors.rb +++ b/lib/lightningcss/errors.rb @@ -1,7 +1,28 @@ # frozen_string_literal: true module LightningCSS - class Error < StandardError; end + class Error < StandardError + attr_reader :type #: String? + attr_reader :location #: LightningCSS::Location? + + #: (String) -> instance + def self.from_json(payload) + parsed = JSON.parse(payload) + location = parsed["location"] && Location.from_hash(parsed["location"]) + message = parsed.fetch("message") + + new(location ? "#{message} at #{location}" : message, type: parsed["type"], location: location) + end + + #: (?String?, ?type: String?, ?location: LightningCSS::Location?) -> void + def initialize(message = nil, type: nil, location: nil) + super(message) + + @type = type + @location = location + end + end + class ParseError < Error; end class OptionError < Error; end class BundleError < Error; end diff --git a/lib/lightningcss/location.rb b/lib/lightningcss/location.rb new file mode 100644 index 0000000..ec18aa2 --- /dev/null +++ b/lib/lightningcss/location.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module LightningCSS + Location = Data.define( + :filename, #: String? + :line, #: Integer + :column #: Integer + ) + + class Location + #: (Hash[String, untyped]) -> LightningCSS::Location + def self.from_hash(hash) + new(filename: hash["filename"], line: hash.fetch("line"), column: hash.fetch("column")) + end + + #: () -> String + def to_s + "#{filename}:#{line}:#{column + 1}" + end + end +end diff --git a/lib/lightningcss/parse_result.rb b/lib/lightningcss/parse_result.rb index 46124a6..aecd9f2 100644 --- a/lib/lightningcss/parse_result.rb +++ b/lib/lightningcss/parse_result.rb @@ -9,7 +9,7 @@ module LightningCSS class ParseResult attr_reader :source #: String? attr_reader :stylesheet #: Hash[String, untyped] - attr_reader :warnings #: Array[String] + attr_reader :warnings #: Array[LightningCSS::Warning] #: (String, ?String?) -> LightningCSS::ParseResult def self.from_json(payload, source = nil) @@ -18,11 +18,11 @@ def self.from_json(payload, source = nil) new( source: source, stylesheet: parsed.fetch("stylesheet"), - warnings: parsed.fetch("warnings") + warnings: parsed.fetch("warnings").map { |warning| Warning.from_hash(warning) } ) end - #: (stylesheet: Hash[String, untyped], warnings: Array[String], ?source: String?) -> void + #: (stylesheet: Hash[String, untyped], warnings: Array[LightningCSS::Warning], ?source: String?) -> void def initialize(stylesheet:, warnings:, source: nil) @source = source @stylesheet = stylesheet.freeze @@ -44,7 +44,7 @@ def warnings? #: () -> String def inspect parts = ["rules=#{stylesheet.fetch("rules").length}"] - parts << "warnings=#{warnings.inspect}" if warnings? + parts << "warnings=#{warnings.map(&:to_s).inspect}" if warnings? "#<#{self.class.name} #{parts.join(" ")}>" end diff --git a/lib/lightningcss/result.rb b/lib/lightningcss/result.rb index 8197293..efcf3bf 100644 --- a/lib/lightningcss/result.rb +++ b/lib/lightningcss/result.rb @@ -13,7 +13,7 @@ module LightningCSS class Result attr_reader :code #: String attr_reader :exports #: Hash[String, String]? - attr_reader :warnings #: Array[String] + attr_reader :warnings #: Array[LightningCSS::Warning] #: (String) -> LightningCSS::Result def self.from_json(payload) @@ -22,11 +22,11 @@ def self.from_json(payload) new( code: parsed.fetch("code"), exports: parsed["exports"], - warnings: parsed.fetch("warnings") + warnings: parsed.fetch("warnings").map { |warning| Warning.from_hash(warning) } ) end - #: (code: String, warnings: Array[String], ?exports: Hash[String, String]?) -> void + #: (code: String, warnings: Array[LightningCSS::Warning], ?exports: Hash[String, String]?) -> void def initialize(code:, warnings:, exports: nil) @code = code @exports = exports @@ -49,7 +49,7 @@ def to_s def inspect parts = ["code=#{code.inspect}"] parts << "exports=#{exports.inspect}" if exports - parts << "warnings=#{warnings.inspect}" if warnings? + parts << "warnings=#{warnings.map(&:to_s).inspect}" if warnings? "#<#{self.class.name} #{parts.join(" ")}>" end diff --git a/lib/lightningcss/warning.rb b/lib/lightningcss/warning.rb new file mode 100644 index 0000000..527ba64 --- /dev/null +++ b/lib/lightningcss/warning.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module LightningCSS + class Warning + attr_reader :message #: String + attr_reader :type #: String? + attr_reader :location #: LightningCSS::Location? + + #: (Hash[String, untyped]) -> LightningCSS::Warning + def self.from_hash(hash) + new( + message: hash.fetch("message"), + type: hash["type"], + location: hash["location"] && Location.from_hash(hash["location"]) + ) + end + + #: (message: String, ?type: String?, ?location: LightningCSS::Location?) -> void + def initialize(message:, type: nil, location: nil) + @message = message + @type = type + @location = location + + freeze + end + + #: () -> Hash[Symbol, untyped] + def to_h + { message: message, type: type, location: location&.to_h } + end + + #: () -> String + def to_s + location ? "#{message} at #{location}" : message + end + + alias to_str to_s + + #: (untyped) -> bool + def ==(other) + case other + when Warning then to_h == other.to_h + when String then to_s == other + else false + end + end + + alias eql? == + + #: () -> Integer + def hash + to_h.hash + end + + #: () -> String + def inspect + "#<#{self.class.name} #{to_s.inspect}>" + end + end +end diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 98964cf..8421dea 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -18,11 +18,10 @@ use std::ptr; use std::sync::{Arc, RwLock}; use lightningcss::bundler::{BundleErrorKind, Bundler, FileProvider}; -use lightningcss::error::Error; use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleAttribute, StyleSheet}; use lightningcss::visitor::Visit; -use crate::options::{ParseResult, TransformOptions, TransformResult}; +use crate::options::{Diagnostic, ParseResult, TransformOptions, TransformResult}; use crate::scope::Scoper; pub const VERSION: &str = env!("GEM_VERSION"); @@ -57,7 +56,7 @@ impl LightningCssResult { fn err(failure: Failure) -> Self { Self { value: ptr::null_mut(), - error: into_c_string(failure.message), + error: into_c_string(serde_json::to_string(&failure.diagnostic).unwrap_or(failure.diagnostic.message)), code: failure.code, } } @@ -65,42 +64,35 @@ impl LightningCssResult { pub struct Failure { code: LightningCssErrorCode, - message: String, -} - -fn describe(error: &Error) -> String { - match &error.loc { - Some(loc) => format!("{} at {}:{}:{}", error.kind, loc.filename, loc.line + 1, loc.column), - None => error.kind.to_string(), - } + diagnostic: Diagnostic, } impl Failure { - fn parse(message: impl Into) -> Self { + fn parse(diagnostic: impl Into) -> Self { Self { code: LightningCssErrorCode::Parse, - message: message.into(), + diagnostic: diagnostic.into(), } } - fn option(message: impl Into) -> Self { + fn option(diagnostic: impl Into) -> Self { Self { code: LightningCssErrorCode::Option, - message: message.into(), + diagnostic: diagnostic.into(), } } - fn bundle(message: impl Into) -> Self { + fn bundle(diagnostic: impl Into) -> Self { Self { code: LightningCssErrorCode::Bundle, - message: message.into(), + diagnostic: diagnostic.into(), } } - fn internal(message: impl Into) -> Self { + fn internal(diagnostic: impl Into) -> Self { Self { code: LightningCssErrorCode::Internal, - message: message.into(), + diagnostic: diagnostic.into(), } } } @@ -143,11 +135,12 @@ fn parse_source<'i>(code: &'i str, options: &TransformOptions) -> Result Result Result Result Failure::parse(describe(&error)), - _ => Failure::bundle(describe(&error)), + BundleErrorKind::ParserError(_) => Failure::parse(Diagnostic::from_error(&error)), + _ => Failure::bundle(Diagnostic::from_error(&error)), })?; if let Some(fragment) = &options.scope { @@ -278,7 +272,7 @@ fn bundle_source(path: &str, options: &TransformOptions) -> Result Result { pub stylesheet: T, - pub warnings: Vec, + pub warnings: Vec, } #[derive(Debug, Serialize)] @@ -145,5 +146,66 @@ pub struct TransformResult { pub code: String, #[serde(skip_serializing_if = "Option::is_none")] pub exports: Option>, - pub warnings: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Diagnostic { + pub message: String, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +#[derive(Debug, Serialize)] +pub struct Location { + pub filename: Option, + pub line: u32, + pub column: u32, +} + +impl Diagnostic { + pub fn from_error(error: &Error) -> Self { + Self { + message: error.kind.to_string(), + kind: serde_json::to_value(&error.kind).ok().as_ref().and_then(type_of), + location: error.loc.as_ref().map(|location| Location { + filename: (!location.filename.is_empty()).then(|| location.filename.clone()), + line: location.line + 1, + column: location.column.saturating_sub(1), + }), + } + } +} + +impl From for Diagnostic { + fn from(message: String) -> Self { + Self { + message, + kind: None, + location: None, + } + } +} + +impl From<&str> for Diagnostic { + fn from(message: &str) -> Self { + Self::from(message.to_string()) + } +} + +fn type_of(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(name) => Some(name.clone()), + serde_json::Value::Object(fields) => match fields.get("type") { + Some(serde_json::Value::String(name)) => Some(name.clone()), + _ if fields.len() == 1 => fields.iter().next().map(|(name, inner)| match inner.get("type") { + Some(serde_json::Value::String(inner_name)) => inner_name.clone(), + _ => name.clone(), + }), + _ => None, + }, + _ => None, + } } diff --git a/rust/tests/bundle_test.rs b/rust/tests/bundle_test.rs index c0ad2f5..a7ece82 100644 --- a/rust/tests/bundle_test.rs +++ b/rust/tests/bundle_test.rs @@ -6,6 +6,12 @@ const ENTRY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/fixtures/bundl const MISSING: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/fixtures/bundle/missing.css"); const BROKEN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/fixtures/bundle/broken.css"); +fn message_of(error: &str) -> String { + let parsed: serde_json::Value = serde_json::from_str(error).unwrap(); + + parsed["message"].as_str().unwrap().to_string() +} + fn bundle(path: &str, options: &str) -> Result { let path = CString::new(path).unwrap(); let options = CString::new(options).unwrap(); @@ -15,7 +21,7 @@ fn bundle(path: &str, options: &str) -> Result { let outcome = if result.error.is_null() { Ok(unsafe { CStr::from_ptr(result.value) }.to_str().unwrap().to_string()) } else { - Err(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap().to_string()) + Err(message_of(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap())) }; unsafe { lightningcss_result_free(result) }; diff --git a/rust/tests/parse_test.rs b/rust/tests/parse_test.rs index d641a37..a92ae3c 100644 --- a/rust/tests/parse_test.rs +++ b/rust/tests/parse_test.rs @@ -2,6 +2,12 @@ use lightningcss_ffi::{lightningcss_parse, lightningcss_result_free, LightningCs use std::ffi::{CStr, CString}; +fn message_of(error: &str) -> String { + let parsed: serde_json::Value = serde_json::from_str(error).unwrap(); + + parsed["message"].as_str().unwrap().to_string() +} + fn parse(code: &str, options: &str) -> LightningCssResult { let code = CString::new(code).unwrap(); let options = CString::new(options).unwrap(); @@ -17,7 +23,7 @@ fn answer(code: &str, options: &str) -> Result { Ok(serde_json::from_str(json).unwrap()) } else { - Err(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap().to_string()) + Err(message_of(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap())) }; unsafe { lightningcss_result_free(result) }; diff --git a/rust/tests/transform_test.rs b/rust/tests/transform_test.rs index 1380aa0..6ff7baf 100644 --- a/rust/tests/transform_test.rs +++ b/rust/tests/transform_test.rs @@ -9,11 +9,17 @@ use std::ptr; type Call = unsafe extern "C" fn(*const c_char, *const c_char) -> LightningCssResult; +fn message_of(error: &str) -> String { + let parsed: serde_json::Value = serde_json::from_str(error).unwrap(); + + parsed["message"].as_str().unwrap().to_string() +} + fn answer(result: LightningCssResult) -> Result { let outcome = if result.error.is_null() { Ok(unsafe { CStr::from_ptr(result.value) }.to_str().unwrap().to_string()) } else { - Err(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap().to_string()) + Err(message_of(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap())) }; unsafe { lightningcss_result_free(result) }; @@ -119,7 +125,31 @@ fn transform_answers_an_error_for_css_it_could_not_read() { assert_eq!( answer, - Err("Expected identifier in class selector, got WhiteSpace(\" \") at :1:2".to_string()) + Err("Expected identifier in class selector, got WhiteSpace(\" \")".to_string()) + ); +} + +#[test] +fn transform_answers_where_it_could_not_read_as_json() { + let code = CString::new(".a { color: red; }\n.b, { color: blue; }\n").unwrap(); + let options = CString::new(r#"{"filename":"main.css"}"#).unwrap(); + let result = unsafe { lightningcss_transform(code.as_ptr(), options.as_ptr()) }; + let error: serde_json::Value = + serde_json::from_str(unsafe { CStr::from_ptr(result.error) }.to_str().unwrap()).unwrap(); + + unsafe { lightningcss_result_free(result) }; + + assert_eq!( + error, + serde_json::json!({ + "message": "Invalid empty selector", + "type": "SelectorError", + "location": { + "filename": "main.css", + "line": 2, + "column": 4, + }, + }) ); } diff --git a/sig/lightningcss/errors.rbs b/sig/lightningcss/errors.rbs index 2d58af3..ac9f23a 100644 --- a/sig/lightningcss/errors.rbs +++ b/sig/lightningcss/errors.rbs @@ -2,6 +2,15 @@ module LightningCSS class Error < StandardError + attr_reader type: String? + + attr_reader location: LightningCSS::Location? + + # : (String) -> instance + def self.from_json: (String) -> instance + + # : (?String?, ?type: String?, ?location: LightningCSS::Location?) -> void + def initialize: (?String?, ?type: String?, ?location: LightningCSS::Location?) -> void end class ParseError < Error diff --git a/sig/lightningcss/location.rbs b/sig/lightningcss/location.rbs new file mode 100644 index 0000000..e1c1c45 --- /dev/null +++ b/sig/lightningcss/location.rbs @@ -0,0 +1,26 @@ +# Generated from lib/lightningcss/location.rb with RBS::Inline + +module LightningCSS + class Location < Data + attr_reader filename(): String? + + attr_reader line(): Integer + + attr_reader column(): Integer + + def self.new: (String? filename, Integer line, Integer column) -> instance + | (filename: String?, line: Integer, column: Integer) -> instance + + def self.members: () -> [ :filename, :line, :column ] + + def members: () -> [ :filename, :line, :column ] + end + + class Location + # : (Hash[String, untyped]) -> LightningCSS::Location + def self.from_hash: (Hash[String, untyped]) -> LightningCSS::Location + + # : () -> String + def to_s: () -> String + end +end diff --git a/sig/lightningcss/parse_result.rbs b/sig/lightningcss/parse_result.rbs index 49c499b..819ef88 100644 --- a/sig/lightningcss/parse_result.rbs +++ b/sig/lightningcss/parse_result.rbs @@ -10,13 +10,13 @@ module LightningCSS attr_reader stylesheet: Hash[String, untyped] - attr_reader warnings: Array[String] + attr_reader warnings: Array[LightningCSS::Warning] # : (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 + # : (stylesheet: Hash[String, untyped], warnings: Array[LightningCSS::Warning], ?source: String?) -> void + def initialize: (stylesheet: Hash[String, untyped], warnings: Array[LightningCSS::Warning], ?source: String?) -> void # : () -> LightningCSS::Node def root: () -> LightningCSS::Node diff --git a/sig/lightningcss/result.rbs b/sig/lightningcss/result.rbs index 9b7a308..caa5787 100644 --- a/sig/lightningcss/result.rbs +++ b/sig/lightningcss/result.rbs @@ -14,13 +14,13 @@ module LightningCSS attr_reader exports: Hash[String, String]? - attr_reader warnings: Array[String] + attr_reader warnings: Array[LightningCSS::Warning] # : (String) -> LightningCSS::Result def self.from_json: (String) -> LightningCSS::Result - # : (code: String, warnings: Array[String], ?exports: Hash[String, String]?) -> void - def initialize: (code: String, warnings: Array[String], ?exports: Hash[String, String]?) -> void + # : (code: String, warnings: Array[LightningCSS::Warning], ?exports: Hash[String, String]?) -> void + def initialize: (code: String, warnings: Array[LightningCSS::Warning], ?exports: Hash[String, String]?) -> void # : () -> bool def warnings?: () -> bool diff --git a/sig/lightningcss/warning.rbs b/sig/lightningcss/warning.rbs new file mode 100644 index 0000000..0beb0ab --- /dev/null +++ b/sig/lightningcss/warning.rbs @@ -0,0 +1,36 @@ +# Generated from lib/lightningcss/warning.rb with RBS::Inline + +module LightningCSS + class Warning + attr_reader message: String + + attr_reader type: String? + + attr_reader location: LightningCSS::Location? + + # : (Hash[String, untyped]) -> LightningCSS::Warning + def self.from_hash: (Hash[String, untyped]) -> LightningCSS::Warning + + # : (message: String, ?type: String?, ?location: LightningCSS::Location?) -> void + def initialize: (message: String, ?type: String?, ?location: LightningCSS::Location?) -> void + + # : () -> Hash[Symbol, untyped] + def to_h: () -> Hash[Symbol, untyped] + + # : () -> String + def to_s: () -> String + + alias to_str to_s + + # : (untyped) -> bool + def ==: (untyped) -> bool + + alias eql? == + + # : () -> Integer + def hash: () -> Integer + + # : () -> String + def inspect: () -> String + end +end diff --git a/test/lightningcss/bundle_test.rb b/test/lightningcss/bundle_test.rb index 0b97a3e..f63e795 100644 --- a/test/lightningcss/bundle_test.rb +++ b/test/lightningcss/bundle_test.rb @@ -86,6 +86,26 @@ class BundleTest < Minitest::Spec assert_equal "Invalid empty selector at #{UNREADABLE}:1:1", error.message end + test "names the file it could not parse in the error" do + error = assert_raises(LightningCSS::ParseError) { LightningCSS.bundle(BROKEN) } + + assert_equal Location.new(filename: UNREADABLE, line: 1, column: 0), error.location + assert_equal "SelectorError", error.type + end + + test "names the file a warning came from" do + warning = LightningCSS.bundle(WARNED).warnings.first + + assert_equal Location.new(filename: IMPORTED, line: 1, column: 8), warning.location + end + + test "names the kind of bundle error" do + error = assert_raises(LightningCSS::BundleError) { LightningCSS.bundle(MISSING) } + + assert_equal "ResolverError", error.type + assert_nil error.location + end + test "refuses a filename, naming every file it reads by the path it read it from" do error = assert_raises(LightningCSS::OptionError) { LightningCSS.bundle(ENTRY, filename: "x.css") } diff --git a/test/lightningcss/errors_test.rb b/test/lightningcss/errors_test.rb index 0ff97af..de0b23c 100644 --- a/test/lightningcss/errors_test.rb +++ b/test/lightningcss/errors_test.rb @@ -23,6 +23,34 @@ class ErrorsTest < Minitest::Spec assert_equal "Invalid empty selector at main.css:2:5", error.message end + test "says where it could not parse, counting lines from 1 and columns from 0" do + css = ".a { color: red; }\n.b, { color: blue; }\n" + error = assert_raises(LightningCSS::ParseError) { LightningCSS.transform(css, filename: "main.css") } + + assert_equal "SelectorError", error.type + assert_equal Location.new(filename: "main.css", line: 2, column: 4), error.location + end + + test "names the kind of token it did not expect" do + error = assert_raises(LightningCSS::ParseError) { LightningCSS.transform_style_attribute("color: red; .") } + + assert_equal "UnexpectedToken", error.type + assert_equal Location.new(filename: nil, line: 1, column: 12), error.location + end + + test "has no location for an option it refused" do + error = assert_raises(LightningCSS::OptionError) { LightningCSS.transform(".a {}", nonsense: true) } + + assert_nil error.type + assert_nil error.location + end + + test "has no location for an option the native library refused" do + error = assert_raises(LightningCSS::OptionError) { LightningCSS.transform(".a {}", targets: { safari: "x" }) } + + assert_nil error.location + end + test "counts the lines of a warning from 1" do css = ".a { color: red; }\n.b, { color: blue; }\n" warnings = LightningCSS.transform(css, filename: "main.css", error_recovery: true).warnings diff --git a/test/lightningcss/location_test.rb b/test/lightningcss/location_test.rb new file mode 100644 index 0000000..0147c70 --- /dev/null +++ b/test/lightningcss/location_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "test_helper" + +module LightningCSS + class LocationTest < Minitest::Spec + test "prints as a filename, a line, and a column counted from 1" do + assert_equal "main.css:2:5", Location.new(filename: "main.css", line: 2, column: 4).to_s + end + + test "prints without a filename when it has none" do + assert_equal ":1:1", Location.new(filename: nil, line: 1, column: 0).to_s + end + + test "is equal to a location with the same fields" do + assert_equal Location.new(filename: "a.css", line: 1, column: 0), Location.new(filename: "a.css", line: 1, column: 0) + end + + test "is frozen" do + assert_predicate Location.new(filename: "a.css", line: 1, column: 0), :frozen? + end + end +end diff --git a/test/lightningcss/warnings_test.rb b/test/lightningcss/warnings_test.rb index 38f9f3e..df7d48c 100644 --- a/test/lightningcss/warnings_test.rb +++ b/test/lightningcss/warnings_test.rb @@ -31,6 +31,48 @@ class WarningsTest < Minitest::Spec refute_predicate result, :warnings? end + test "gives each warning as a warning" do + assert_instance_of Warning, LightningCSS.transform(DEEP).warnings.first + end + + test "says where a warning is, counting lines from 1 and columns from 0" do + warning = LightningCSS.transform(".a { color: red; }\n.b, { color: blue; }\n", filename: "main.css", error_recovery: true).warnings.first + + assert_equal "Invalid empty selector", warning.message + assert_equal "SelectorError", warning.type + assert_equal Location.new(filename: "main.css", line: 2, column: 4), warning.location + end + + test "keeps the message a warning had as a string" do + warning = LightningCSS.transform(DEEP).warnings.first + + assert_equal WARNING, warning.to_s + assert_equal WARNING, warning + assert_equal warning, WARNING + end + + test "has no filename for a stylesheet that was not given one" do + assert_nil LightningCSS.transform(DEEP).warnings.first.location.filename + end + + test "is equal to a warning with the same fields" do + first, second = Array.new(2) { LightningCSS.transform(DEEP).warnings.first } + + assert_equal first, second + assert_equal first.hash, second.hash + end + + test "inspects as its message" do + assert_equal "#", LightningCSS.transform(DEEP).warnings.first.inspect + end + + test "collects warnings as warnings when it parses" do + warning = LightningCSS.parse(DEEP).warnings.first + + assert_instance_of Warning, warning + assert_equal Location.new(filename: nil, line: 1, column: 8), warning.location + end + test "keeps its warnings to itself" do assert_predicate LightningCSS.transform(".a { color: red }").warnings, :frozen? end