diff --git a/lib/live_style/attrs.ex b/lib/live_style/attrs.ex index 59370b5..b0713b9 100644 --- a/lib/live_style/attrs.ex +++ b/lib/live_style/attrs.ex @@ -42,7 +42,9 @@ defmodule LiveStyle.Attrs do def to_list(%__MODULE__{class: class, style: style} = attrs) do result = [] result = if style && style != "", do: [{:style, style} | result], else: result - result = if class && class != "", do: [{:class, attrs} | result], else: result + has_class = is_binary(class) and class != "" + has_prop_classes = is_list(attrs.prop_classes) and attrs.prop_classes != [] + result = if has_class or has_prop_classes, do: [{:class, attrs} | result], else: result result end diff --git a/lib/live_style/class/include.ex b/lib/live_style/class/include.ex index db33d16..4654c0c 100644 --- a/lib/live_style/class/include.ex +++ b/lib/live_style/class/include.ex @@ -73,7 +73,13 @@ defmodule LiveStyle.Class.Include do - `manifest` - Optional manifest to look up local includes (for batch processing) """ @spec resolve(keyword(), atom(), LiveStyle.Manifest.t() | nil) :: keyword() - def resolve(declarations, caller_module, manifest \\ nil) when is_list(declarations) do + def resolve(declarations, caller_module, manifest \\ nil) + + def resolve(declarations, caller_module, manifest) when is_list(declarations) do + do_resolve(declarations, caller_module, manifest, MapSet.new()) + end + + defp do_resolve(declarations, caller_module, manifest, seen) when is_list(declarations) do {includes_list, regular} = Keyword.pop(declarations, :__include__, []) # Normalize includes_list to always be a list @@ -82,15 +88,30 @@ defmodule LiveStyle.Class.Include do base = includes_list |> Enum.reduce([], fn include_ref, acc -> + ref_key = include_ref_key(include_ref, caller_module) + + if MapSet.member?(seen, ref_key) do + raise CompileError, + description: + "LiveStyle: Include cycle detected: #{inspect(ref_key)}. " <> + "Classes cannot include each other in a cycle." + end + included = fetch_included_style(include_ref, caller_module, manifest) - # Recursively resolve includes in the included style - resolved = resolve(included, caller_module, manifest) + resolved = do_resolve(included, caller_module, manifest, MapSet.put(seen, ref_key)) Utils.merge_declarations(acc, resolved) end) Utils.merge_declarations(base, regular) end + defp include_ref_key({module, rule_name}, _caller_module) + when is_atom(module) and is_atom(rule_name), + do: {module, rule_name} + + defp include_ref_key(class_name, caller_module) when is_atom(class_name), + do: {caller_module, class_name} + defp fetch_included_style({module, rule_name}, caller_module, _manifest) when is_atom(module) and is_atom(rule_name) do # Record usage of the included external class for tree shaking diff --git a/lib/live_style/compiler/css/atomic_class.ex b/lib/live_style/compiler/css/atomic_class.ex index 260b235..dfcb792 100644 --- a/lib/live_style/compiler/css/atomic_class.ex +++ b/lib/live_style/compiler/css/atomic_class.ex @@ -50,20 +50,34 @@ defmodule LiveStyle.Compiler.CSS.AtomicClass do # Build declarations for all fallback values # Order preserved: first value is the preferred one, subsequent values are fallbacks # CSS applies in order, so last declaration wins if supported - decls = - Enum.map_join(values, ";", fn val -> - {ltr_prop, ltr_val} = RTL.generate_ltr(property, val) - "#{ltr_prop}:#{ltr_val}" + selector = Selector.build_atomic_class_selector(class_name, selector_suffix, at_rule) + + # Generate LTR and RTL pairs for each fallback value + pairs = + Enum.map(values, fn val -> + ltr = RTL.generate_ltr(property, val) + rtl = RTL.generate_rtl(property, val) + {ltr, rtl} end) - selector = Selector.build_atomic_class_selector(class_name, selector_suffix, at_rule) - ltr_css = "#{selector}{#{decls}}" + ltr_decls = Enum.map_join(pairs, ";", fn {{p, v}, _} -> "#{p}:#{v}" end) + ltr_css = wrap_in_at_rules(at_rule, "#{selector}{#{ltr_decls}}") - # Wrap in at-rule if present (handles nested at-rules) - ltr_css = wrap_in_at_rules(at_rule, ltr_css) + # Generate RTL CSS if any value differs in RTL + rtl_decls = + Enum.map_join(pairs, ";", fn {{lp, lv}, rtl} -> + {rp, rv} = rtl || {lp, lv} + "#{rp}:#{rv}" + end) - # RTL handling for arrays is more complex, skip for now - {ltr_css, nil} + rtl_css = + if rtl_decls == ltr_decls do + nil + else + wrap_in_at_rules(at_rule, "html[dir=\"rtl\"] #{selector}{#{rtl_decls}}") + end + + {ltr_css, rtl_css} end @doc false diff --git a/lib/live_style/compiler/css/theme_classes.ex b/lib/live_style/compiler/css/theme_classes.ex index e0fd1d3..6bf8a3f 100644 --- a/lib/live_style/compiler/css/theme_classes.ex +++ b/lib/live_style/compiler/css/theme_classes.ex @@ -115,7 +115,13 @@ defmodule LiveStyle.Compiler.CSS.ThemeClasses do end end - # Check if a list is a conditional value list - defp conditional_list?([{key, _} | _]) when is_atom(key) or is_binary(key), do: true + # Check if a list is a conditional value list (all elements must be {key, value} tuples) + defp conditional_list?([_ | _] = list) do + Enum.all?(list, fn + {k, _} when is_atom(k) or is_binary(k) -> true + _ -> false + end) + end + defp conditional_list?(_), do: false end diff --git a/lib/live_style/compiler/css/vars.ex b/lib/live_style/compiler/css/vars.ex index a7479b6..3e178f7 100644 --- a/lib/live_style/compiler/css/vars.ex +++ b/lib/live_style/compiler/css/vars.ex @@ -123,8 +123,14 @@ defmodule LiveStyle.Compiler.CSS.Vars do [{at_rules, ident, to_string(value)}] end - # Check if a list is a conditional value list (keyword-like with :default or @-rule keys) - defp conditional_list?([{key, _} | _]) when is_atom(key) or is_binary(key), do: true + # Check if a list is a conditional value list (all elements must be {key, value} tuples) + defp conditional_list?([_ | _] = list) do + Enum.all?(list, fn + {k, _} when is_atom(k) or is_binary(k) -> true + _ -> false + end) + end + defp conditional_list?(_), do: false # Handle default key with simple value @@ -154,9 +160,17 @@ defmodule LiveStyle.Compiler.CSS.Vars do default = Keyword.get(list, :default) || get_string_key(list, "default") case default do - nil -> list |> List.first() |> elem(1) |> extract_initial_value() - val when is_binary(val) -> val - val -> extract_initial_value(val) + nil -> + case list do + [{_k, v} | _] -> extract_initial_value(v) + [] -> "" + end + + val when is_binary(val) -> + val + + val -> + extract_initial_value(val) end end diff --git a/lib/live_style/compiler/css/writer/file.ex b/lib/live_style/compiler/css/writer/file.ex index d2321e0..88140cc 100644 --- a/lib/live_style/compiler/css/writer/file.ex +++ b/lib/live_style/compiler/css/writer/file.ex @@ -24,10 +24,22 @@ defmodule LiveStyle.Compiler.CSS.Writer.File do defp write_file(path, content) do dir = Path.dirname(path) + temp_path = Path.join(dir, ".#{Path.basename(path)}.tmp") with :ok <- File.mkdir_p(dir), - :ok <- File.write(path, content) do - {:ok, :written} + :ok <- File.write(temp_path, content) do + case File.rename(temp_path, path) do + :ok -> + {:ok, :written} + + {:error, reason} -> + File.rm(temp_path) + {:error, reason} + end + else + {:error, reason} -> + File.rm(temp_path) + {:error, reason} end end end diff --git a/lib/live_style/compiler/module_data.ex b/lib/live_style/compiler/module_data.ex index b5e5ca4..1ce536c 100644 --- a/lib/live_style/compiler/module_data.ex +++ b/lib/live_style/compiler/module_data.ex @@ -25,9 +25,19 @@ defmodule LiveStyle.Compiler.ModuleData do def write(module, data) when is_atom(module) and is_map(data) do path = module_path(module) dir = Path.dirname(path) - File.mkdir_p!(dir) - File.write!(path, :erlang.term_to_binary(data)) + + temp_path = path <> ".tmp" + + try do + File.write!(temp_path, :erlang.term_to_binary(data)) + File.rename!(temp_path, path) + rescue + error -> + File.rm(temp_path) + reraise error, __STACKTRACE__ + end + :ok end @@ -41,12 +51,18 @@ defmodule LiveStyle.Compiler.ModuleData do path = module_path(module) if File.exists?(path) do - path - |> File.read!() - |> :erlang.binary_to_term() + case File.read(path) do + {:ok, binary} -> + :erlang.binary_to_term(binary) + + {:error, _} -> + nil + end else nil end + catch + :error, :badarg -> nil end @doc """ @@ -62,11 +78,9 @@ defmodule LiveStyle.Compiler.ModuleData do {:ok, files} -> files |> Enum.filter(&String.ends_with?(&1, ".etf")) - |> Enum.map(fn file -> + |> Enum.flat_map(fn file -> path = Path.join(modules_dir(), file) - data = path |> File.read!() |> :erlang.binary_to_term() - module = data[:module] - {module, data} + read_module_file(path) end) {:error, _} -> @@ -86,12 +100,16 @@ defmodule LiveStyle.Compiler.ModuleData do for file <- files, String.ends_with?(file, ".etf") do path = Path.join(modules_dir(), file) - with {:ok, binary} <- File.read(path), - data <- :erlang.binary_to_term(binary), - module when is_atom(module) <- data[:module] do - unless MapSet.member?(active_modules, module) do - File.rm(path) + try do + with {:ok, binary} <- File.read(path), + data <- :erlang.binary_to_term(binary), + module when is_atom(module) <- data[:module] do + unless MapSet.member?(active_modules, module) do + File.rm(path) + end end + catch + :error, :badarg -> File.rm(path) end end @@ -113,6 +131,22 @@ defmodule LiveStyle.Compiler.ModuleData do Path.join([build_path, "live_style", to_string(app), "modules"]) end + defp read_module_file(path) do + case File.read(path) do + {:ok, binary} -> + try do + data = :erlang.binary_to_term(binary) + module = data[:module] + if is_atom(module), do: [{module, data}], else: [] + catch + :error, :badarg -> [] + end + + {:error, _} -> + [] + end + end + defp module_path(module) do # Use a hash of the module name to avoid filesystem issues with long names hash = :crypto.hash(:md5, inspect(module)) |> Base.encode16(case: :lower) diff --git a/lib/live_style/compiler/watch.ex b/lib/live_style/compiler/watch.ex index 82be82c..bdc70d4 100644 --- a/lib/live_style/compiler/watch.ex +++ b/lib/live_style/compiler/watch.ex @@ -80,9 +80,14 @@ defmodule LiveStyle.Compiler.Watch do new_hash = compute_modules_hash(modules_dir) if new_hash != last_hash do - # Content changed - merge and regenerate - LiveStyle.Storage.merge_module_data() - _ = run_once_fun.(output, input) + try do + LiveStyle.Storage.merge_module_data() + _ = run_once_fun.(output, input) + rescue + e -> + require Logger + Logger.error("LiveStyle watch: regeneration failed: #{Exception.message(e)}") + end end new_hash @@ -96,7 +101,7 @@ defmodule LiveStyle.Compiler.Watch do |> Enum.filter(&String.ends_with?(&1, ".etf")) |> Enum.sort() |> Enum.map(&file_content_hash(modules_dir, &1)) - |> :erlang.md5() + |> then(&:crypto.hash(:md5, &1)) {:error, _} -> <<>> @@ -105,7 +110,7 @@ defmodule LiveStyle.Compiler.Watch do defp file_content_hash(dir, file) do case File.read(Path.join(dir, file)) do - {:ok, content} -> :erlang.md5(content) + {:ok, content} -> :crypto.hash(:md5, content) {:error, _} -> <<>> end end diff --git a/lib/live_style/css_value/normalize.ex b/lib/live_style/css_value/normalize.ex index 580c1c7..a8e8874 100644 --- a/lib/live_style/css_value/normalize.ex +++ b/lib/live_style/css_value/normalize.ex @@ -43,10 +43,16 @@ defmodule LiveStyle.CSSValue.Normalize do # Convert milliseconds to seconds when >= 10ms defp normalize_timings(value) do Regex.replace(@ms_timing_regex, value, fn _, num_str -> - num = String.to_float(num_str <> ".0") |> Float.round(4) + num = + if String.contains?(num_str, ".") do + String.to_float(num_str) + else + String.to_float(num_str <> ".0") + end + |> Float.round(4) if num >= 10 do - seconds = num / 1000 + seconds = Float.round(num / 1000, 4) "#{seconds}s" else "#{num_str}ms" diff --git a/lib/live_style/manifest.ex b/lib/live_style/manifest.ex index 934e851..7f6a980 100644 --- a/lib/live_style/manifest.ex +++ b/lib/live_style/manifest.ex @@ -183,33 +183,21 @@ defmodule LiveStyle.Manifest do sorted_list_get(list, key) end - # Insert or update in a sorted list, maintaining sort order - defp sorted_list_put(list, key, entry) do - case sorted_list_find_index(list, key) do - {:found, index} -> - List.replace_at(list, index, {key, entry}) - - {:insert_at, index} -> - List.insert_at(list, index, {key, entry}) - end - end + # Single-pass insert or update in a sorted list, maintaining sort order + defp sorted_list_put([], key, entry), do: [{key, entry}] - # Get value from sorted list by key (linear search, but could use binary search) - defp sorted_list_get(list, key) do - case List.keyfind(list, key, 0) do - {^key, entry} -> entry - nil -> nil - end - end + defp sorted_list_put([{k, _v} = head | rest], key, entry) when key < k, + do: [{key, entry}, head | rest] - # Find index where key exists or should be inserted - defp sorted_list_find_index(list, key) do - find_index(list, key, 0) - end + defp sorted_list_put([{k, _} | rest], key, entry) when key == k, + do: [{key, entry} | rest] - defp find_index([], _key, index), do: {:insert_at, index} + defp sorted_list_put([head | rest], key, entry), + do: [head | sorted_list_put(rest, key, entry)] - defp find_index([{k, _} | _rest], key, index) when key < k, do: {:insert_at, index} - defp find_index([{k, _} | _rest], key, index) when key == k, do: {:found, index} - defp find_index([_ | rest], key, index), do: find_index(rest, key, index + 1) + # Single-pass get from sorted list with early stop + defp sorted_list_get([], _key), do: nil + defp sorted_list_get([{k, _} | _rest], key) when key < k, do: nil + defp sorted_list_get([{k, entry} | _rest], key) when key == k, do: entry + defp sorted_list_get([_ | rest], key), do: sorted_list_get(rest, key) end diff --git a/lib/live_style/media_query/transform.ex b/lib/live_style/media_query/transform.ex index 503ce45..60fbdd1 100644 --- a/lib/live_style/media_query/transform.ex +++ b/lib/live_style/media_query/transform.ex @@ -30,8 +30,9 @@ defmodule LiveStyle.MediaQuery.Transform do """ # Compile regex patterns at module level - @min_width_regex ~r/@media\s*\(min-width:\s*(\d+(?:\.\d+)?)(px|em|rem)\)/ - @max_width_regex ~r/@media\s*\(max-width:\s*(\d+(?:\.\d+)?)(px|em|rem)\)/ + # Anchored to only match simple min/max-width queries, avoiding mangling complex queries + @min_width_regex ~r/\A@media\s*\(min-width:\s*(\d+(?:\.\d+)?)(px|em|rem)\)\s*\z/ + @max_width_regex ~r/\A@media\s*\(max-width:\s*(\d+(?:\.\d+)?)(px|em|rem)\)\s*\z/ @doc """ Transform a conditional value to implement "last media query wins" semantics. @@ -192,22 +193,22 @@ defmodule LiveStyle.MediaQuery.Transform do # Sort by value ascending sorted = Enum.sort_by(queries, fn {_key, parsed} -> parsed.value end) - sorted - |> Enum.with_index() - |> Enum.map(fn {{old_key, parsed}, index} -> - if index < length(sorted) - 1 do + # Pair each query with the next one using zip for O(n) iteration + pairs = Enum.zip(sorted, tl(sorted) ++ [nil]) + + Enum.map(pairs, fn + {{old_key, parsed}, {_next_key, next_parsed}} -> # Not the last query - add upper bound - {_next_key, next_parsed} = Enum.at(sorted, index + 1) upper_bound = next_parsed.value - 0.01 new_key = "@media (min-width: #{format_value(parsed.value)}#{parsed.unit}) and (max-width: #{format_value(upper_bound)}#{parsed.unit})" {old_key, new_key} - else + + {{old_key, _parsed}, nil} -> # Last query - no transformation {old_key, old_key} - end end) end @@ -221,22 +222,22 @@ defmodule LiveStyle.MediaQuery.Transform do # Sort by value descending (largest first) sorted = Enum.sort_by(queries, fn {_key, parsed} -> -parsed.value end) - sorted - |> Enum.with_index() - |> Enum.map(fn {{old_key, parsed}, index} -> - if index < length(sorted) - 1 do + # Pair each query with the next one using zip for O(n) iteration + pairs = Enum.zip(sorted, tl(sorted) ++ [nil]) + + Enum.map(pairs, fn + {{old_key, parsed}, {_next_key, next_parsed}} -> # Not the last query - add lower bound - {_next_key, next_parsed} = Enum.at(sorted, index + 1) lower_bound = next_parsed.value + 0.01 new_key = "@media (min-width: #{format_value(lower_bound)}#{parsed.unit}) and (max-width: #{format_value(parsed.value)}#{parsed.unit})" {old_key, new_key} - else + + {{old_key, _parsed}, nil} -> # Last query - no transformation {old_key, old_key} - end end) end diff --git a/lib/live_style/runtime/dynamic.ex b/lib/live_style/runtime/dynamic.ex index 6cf8ec5..0fbbb3d 100644 --- a/lib/live_style/runtime/dynamic.ex +++ b/lib/live_style/runtime/dynamic.ex @@ -30,6 +30,16 @@ defmodule LiveStyle.Runtime.Dynamic do {to_css_var_name(prop, prefix), format_css_value(value)} end) else + prop_count = length(all_props) + value_count = length(values_list) + + if prop_count != value_count do + raise ArgumentError, + "Dynamic class :#{name} in #{inspect(module)} expects #{prop_count} " <> + "value(s) but got #{value_count}. " <> + "Expected values for: #{inspect(all_props)}" + end + all_props |> Enum.zip(values_list) |> Enum.map(fn {prop, value} -> diff --git a/lib/live_style/runtime/property_merger.ex b/lib/live_style/runtime/property_merger.ex index b90ee04..77fbf10 100644 --- a/lib/live_style/runtime/property_merger.ex +++ b/lib/live_style/runtime/property_merger.ex @@ -31,7 +31,32 @@ defmodule LiveStyle.Runtime.PropertyMerger do """ @spec merge(prop_classes(), accumulator()) :: accumulator() def merge(prop_classes, acc) when is_list(prop_classes) do - Enum.reduce(prop_classes, acc, &merge_prop/2) + # Build a map from the accumulator for O(1) lookups + acc_map = Map.new(acc, fn {k, v} -> {normalize_key(k), {k, v}} end) + # Track insertion order as a list of normalized keys + acc_keys = Enum.map(acc, fn {k, _v} -> normalize_key(k) end) + + # Merge each property class using map for O(1) ops, list for order + {merged_map, merged_keys} = + Enum.reduce(prop_classes, {acc_map, acc_keys}, fn + {prop, :__unset__}, {map, keys} -> + nk = normalize_key(prop) + {Map.delete(map, nk), List.delete(keys, nk)} + + {prop, class}, {map, keys} -> + nk = normalize_key(prop) + existing? = Map.has_key?(map, nk) + new_map = Map.put(map, nk, {prop, class}) + + new_keys = + if existing?, + do: keys, + else: keys ++ [nk] + + {new_map, new_keys} + end) + + Enum.map(merged_keys, fn nk -> merged_map[nk] end) end @doc """ diff --git a/lib/live_style/runtime/ref_resolver.ex b/lib/live_style/runtime/ref_resolver.ex index 368926a..c0c0b1a 100644 --- a/lib/live_style/runtime/ref_resolver.ex +++ b/lib/live_style/runtime/ref_resolver.ex @@ -48,54 +48,56 @@ defmodule LiveStyle.Runtime.RefResolver do def resolve(_module, {other_module, name}, _property_classes) when is_atom(other_module) and is_atom(name) do - case Atom.to_string(other_module) do - <<"Elixir.", _::binary>> -> - other_prop_classes = other_module.__live_style__(:property_classes) - prop_classes = Keyword.get(other_prop_classes, name, []) - {:static, prop_classes} - - _ -> - :skip + if live_style_module?(other_module) do + other_prop_classes = other_module.__live_style__(:property_classes) + prop_classes = Keyword.get(other_prop_classes, name, []) + {:static, prop_classes} + else + :skip end end # Cross-module dynamic class: {{OtherModule, :name}, args} def resolve(_module, {{other_module, name}, args}, _property_classes) when is_atom(other_module) and is_atom(name) do - case Atom.to_string(other_module) do - <<"Elixir.", _::binary>> -> - other_prop_classes = other_module.__live_style__(:property_classes) - prop_classes = Keyword.get(other_prop_classes, name, []) - - dynamic_names = other_module.__live_style__(:dynamic_names) - - if name in dynamic_names do - fn_name = :"__dynamic_#{name}__" - var_list = apply(other_module, fn_name, [args]) - {:dynamic, prop_classes, var_list || []} - else - {:static, prop_classes} - end - - _ -> - :skip + if live_style_module?(other_module) do + other_prop_classes = other_module.__live_style__(:property_classes) + prop_classes = Keyword.get(other_prop_classes, name, []) + dynamic_names = other_module.__live_style__(:dynamic_names) + resolve_dynamic(other_module, name, args, prop_classes, dynamic_names) + else + :skip end end def resolve(module, {name, args}, property_classes) when is_atom(name) do - dynamic_names = module.__live_style__(:dynamic_names) + if function_exported?(module, :__live_style__, 1) do + dynamic_names = module.__live_style__(:dynamic_names) + prop_classes = Keyword.get(property_classes, name, []) + resolve_dynamic(module, name, args, prop_classes, dynamic_names) + else + :skip + end + end + def resolve(_module, _ref, _property_classes), do: :skip + + defp resolve_dynamic(module, name, args, prop_classes, dynamic_names) do if name in dynamic_names do - # Dynamic classes: get property_classes from compile-time map, compute var_list at runtime - prop_classes = Keyword.get(property_classes, name, []) fn_name = :"__dynamic_#{name}__" - var_list = apply(module, fn_name, [args]) - {:dynamic, prop_classes, var_list || []} + + if function_exported?(module, fn_name, 1) do + var_list = apply(module, fn_name, [args]) + {:dynamic, prop_classes, var_list || []} + else + {:static, prop_classes} + end else - prop_classes = Keyword.get(property_classes, name, []) {:static, prop_classes} end end - def resolve(_module, _ref, _property_classes), do: :skip + defp live_style_module?(module) do + Code.ensure_loaded?(module) and function_exported?(module, :__live_style__, 1) + end end diff --git a/lib/live_style/selector/condition.ex b/lib/live_style/selector/condition.ex index 1bd48a5..a4d62e2 100644 --- a/lib/live_style/selector/condition.ex +++ b/lib/live_style/selector/condition.ex @@ -34,15 +34,39 @@ defmodule LiveStyle.Selector.Condition do # Find @-rule that follows a pseudo-selector # e.g., ":hover@media ..." -> {":hover", "@media ..."} + # Skips @ signs inside brackets [] or parentheses () to avoid + # misinterpreting attribute selectors like :not([data-theme="@media"]) defp find_at_rule_after_pseudo(selector) do - case :binary.match(selector, "@") do - :nomatch -> - nil + find_top_level_at(selector, 0, 0, 0) + end - {pos, _len} -> - pseudo = binary_part(selector, 0, pos) - at_rule = binary_part(selector, pos, byte_size(selector) - pos) - {pseudo, at_rule} + defp find_top_level_at(selector, pos, bracket_depth, paren_depth) do + if pos >= byte_size(selector) do + nil + else + <<_::binary-size(pos), char, _::binary>> = selector + + case char do + ?[ -> + find_top_level_at(selector, pos + 1, bracket_depth + 1, paren_depth) + + ?] -> + find_top_level_at(selector, pos + 1, max(bracket_depth - 1, 0), paren_depth) + + ?( -> + find_top_level_at(selector, pos + 1, bracket_depth, paren_depth + 1) + + ?) -> + find_top_level_at(selector, pos + 1, bracket_depth, max(paren_depth - 1, 0)) + + ?@ when bracket_depth == 0 and paren_depth == 0 -> + pseudo = binary_part(selector, 0, pos) + at_rule = binary_part(selector, pos, byte_size(selector) - pos) + {pseudo, at_rule} + + _ -> + find_top_level_at(selector, pos + 1, bracket_depth, paren_depth) + end end end diff --git a/lib/live_style/shorthand_behavior/accept_shorthands.ex b/lib/live_style/shorthand_behavior/accept_shorthands.ex index e7b6654..8047d91 100644 --- a/lib/live_style/shorthand_behavior/accept_shorthands.ex +++ b/lib/live_style/shorthand_behavior/accept_shorthands.ex @@ -134,14 +134,16 @@ defmodule LiveStyle.ShorthandBehavior.AcceptShorthands do defp apply_expansion({:complex, :overscroll_behavior}, value) do parts = split_css_value(value) - [x, y] = - case parts do - [single] -> [single, single] - [a, b] -> [a, b] - _ -> [nil, nil] - end + case parts do + [single] -> + [{"overscroll-behavior-x", single}, {"overscroll-behavior-y", single}] - [{"overscroll-behavior-x", x}, {"overscroll-behavior-y", y}] + [a, b] -> + [{"overscroll-behavior-x", a}, {"overscroll-behavior-y", b}] + + _ -> + [{"overscroll-behavior", value}] + end end defp apply_expansion({:complex, :contain_intrinsic_size}, nil) do diff --git a/lib/live_style/shorthand_behavior/flatten_shorthands.ex b/lib/live_style/shorthand_behavior/flatten_shorthands.ex index 4f27074..92c002d 100644 --- a/lib/live_style/shorthand_behavior/flatten_shorthands.ex +++ b/lib/live_style/shorthand_behavior/flatten_shorthands.ex @@ -137,7 +137,12 @@ defmodule LiveStyle.ShorthandBehavior.FlattenShorthands do defp extract_important(value) do if String.ends_with?(value, "!important") do - {String.trim(String.replace(value, "!important", "")), " !important"} + base = + value + |> String.replace_suffix("!important", "") + |> String.trim_trailing() + + {base, " !important"} else {value, ""} end diff --git a/lib/live_style/storage.ex b/lib/live_style/storage.ex index beef036..8502373 100644 --- a/lib/live_style/storage.ex +++ b/lib/live_style/storage.ex @@ -513,8 +513,6 @@ defmodule LiveStyle.Storage do lock = lock_path() # Ensure directory exists lock |> Path.dirname() |> File.mkdir_p!() - # Clean stale locks before attempting to acquire - maybe_clean_stale_lock(lock) acquire_lock(lock, @lock_timeout) try do @@ -525,9 +523,9 @@ defmodule LiveStyle.Storage do end defp maybe_clean_stale_lock(lock) do - case File.stat(lock) do + case File.stat(lock, time: :posix) do {:ok, %{mtime: mtime}} -> - age_seconds = System.os_time(:second) - to_unix_time(mtime) + age_seconds = System.os_time(:second) - mtime if age_seconds > @stale_lock_threshold_seconds do File.rm_rf(lock) @@ -538,17 +536,13 @@ defmodule LiveStyle.Storage do end end - defp to_unix_time({{year, month, day}, {hour, min, sec}}) do - :calendar.datetime_to_gregorian_seconds({{year, month, day}, {hour, min, sec}}) - - :calendar.datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}}) - end - defp acquire_lock(lock, timeout) when timeout > 0 do case File.mkdir(lock) do :ok -> :ok {:error, :eexist} -> + maybe_clean_stale_lock(lock) Process.sleep(@lock_retry_interval) acquire_lock(lock, timeout - @lock_retry_interval) @@ -629,7 +623,6 @@ defmodule LiveStyle.Storage do defp with_usage_lock(fun) when is_function(fun, 0) do lock = usage_lock_path() lock |> Path.dirname() |> File.mkdir_p!() - maybe_clean_stale_lock(lock) acquire_lock(lock, @lock_timeout) try do diff --git a/lib/mix/tasks/live_style.audit.ex b/lib/mix/tasks/live_style.audit.ex index 3b46065..5d17bbf 100644 --- a/lib/mix/tasks/live_style.audit.ex +++ b/lib/mix/tasks/live_style.audit.ex @@ -96,16 +96,17 @@ defmodule Mix.Tasks.LiveStyle.Audit do defp extract_definitions(file) do content = File.read!(file) + module = extract_module_name(content) # Match class(:name, ...) definitions # The pattern must be at the start of a line (after whitespace) to avoid matching # inside other expressions + # Use return: :index to get byte offsets for accurate line number computation ~r/^\s*class\(\s*:([a-z_][a-z0-9_]*)\s*,/m - |> Regex.scan(content) - |> Enum.map(fn [full_match, class_name] -> - # Find line number by locating the match in content - line = count_lines_for_match(content, full_match) - module = extract_module_name(content) + |> Regex.scan(content, return: :index) + |> Enum.map(fn [{match_start, _match_len}, {cap_start, cap_len}] -> + class_name = binary_part(content, cap_start, cap_len) + line = count_lines_before(content, match_start) %{ module: module, @@ -123,18 +124,11 @@ defmodule Mix.Tasks.LiveStyle.Audit do end end - defp count_lines_for_match(content, match) do - # Find the position of the match and count lines up to that point - case :binary.match(content, match) do - {pos, _len} -> - content - |> :binary.part(0, pos) - |> String.split("\n") - |> length() - - :nomatch -> - 0 - end + defp count_lines_before(content, byte_offset) do + content + |> binary_part(0, byte_offset) + |> String.split("\n") + |> length() end defp find_class_references(path) do diff --git a/lib/mix/tasks/live_style.inspect.ex b/lib/mix/tasks/live_style.inspect.ex index e04d4ca..982b0bd 100644 --- a/lib/mix/tasks/live_style.inspect.ex +++ b/lib/mix/tasks/live_style.inspect.ex @@ -80,11 +80,11 @@ defmodule Mix.Tasks.LiveStyle.Inspect do print_list(module) opts[:css] && class_names != [] -> - class_atoms = Enum.map(class_names, &String.to_existing_atom/1) + class_atoms = Enum.map(class_names, &to_existing_atom_or_halt!(&1, module)) print_css(module, class_atoms) class_names != [] -> - class_atoms = Enum.map(class_names, &String.to_existing_atom/1) + class_atoms = Enum.map(class_names, &to_existing_atom_or_halt!(&1, module)) print_inspection(module, class_atoms) true -> @@ -93,6 +93,15 @@ defmodule Mix.Tasks.LiveStyle.Inspect do end end + defp to_existing_atom_or_halt!(name, module) do + String.to_existing_atom(name) + rescue + ArgumentError -> + Mix.shell().error("Unknown class #{inspect(name)} for #{inspect(module)}") + Mix.shell().error("Use --list to see available classes.") + exit({:shutdown, 1}) + end + defp print_list(module) do classes = LiveStyle.Dev.list(module)