Files
bun.sh/src/css/printer.zig
2025-05-31 18:52:18 -07:00

585 lines
23 KiB
Zig

const std = @import("std");
const Allocator = std.mem.Allocator;
const bun = @import("bun");
pub const css = @import("./css_parser.zig");
pub const css_values = @import("./values/values.zig");
const DashedIdent = css_values.ident.DashedIdent;
pub const Error = css.Error;
const Location = css.Location;
const PrintErr = css.PrintErr;
const ArrayList = std.ArrayListUnmanaged;
const sourcemap = @import("./sourcemap.zig");
/// Options that control how CSS is serialized to a string.
pub const PrinterOptions = struct {
/// Whether to minify the CSS, i.e. remove white space.
minify: bool = false,
/// An optional reference to a source map to write mappings into.
/// (Available when the `sourcemap` feature is enabled.)
source_map: ?*sourcemap.SourceMap = null,
/// An optional project root path, used to generate relative paths for sources used in CSS module hashes.
project_root: ?[]const u8 = null,
/// Targets to output the CSS for.
targets: Targets,
/// Whether to analyze dependencies (i.e. `@import` and `url()`).
/// If true, the dependencies are returned as part of the
/// [ToCssResult](super::stylesheet::ToCssResult).
///
/// When enabled, `@import` and `url()` dependencies
/// are replaced with hashed placeholders that can be replaced with the final
/// urls later (after bundling).
analyze_dependencies: ?css.dependencies.DependencyOptions = null,
/// A mapping of pseudo classes to replace with class names that can be applied
/// from JavaScript. Useful for polyfills, for example.
pseudo_classes: ?PseudoClasses = null,
public_path: []const u8 = "",
pub fn default() PrinterOptions {
return .{
.targets = Targets{
.browsers = null,
},
};
}
pub fn defaultWithMinify(minify: bool) PrinterOptions {
return .{
.targets = Targets{
.browsers = null,
},
.minify = minify,
};
}
};
/// A mapping of user action pseudo classes to replace with class names.
///
/// See [PrinterOptions](PrinterOptions).
const PseudoClasses = struct {
/// The class name to replace `:hover` with.
hover: ?[]const u8 = null,
/// The class name to replace `:active` with.
active: ?[]const u8 = null,
/// The class name to replace `:focus` with.
focus: ?[]const u8 = null,
/// The class name to replace `:focus-visible` with.
focus_visible: ?[]const u8 = null,
/// The class name to replace `:focus-within` with.
focus_within: ?[]const u8 = null,
};
pub const Targets = css.targets.Targets;
pub const Features = css.targets.Features;
pub const ImportInfo = struct {
import_records: *const bun.BabyList(bun.ImportRecord),
/// bundle_v2.graph.ast.items(.url_for_css)
ast_urls_for_css: []const []const u8,
/// bundle_v2.graph.input_files.items(.unique_key_for_additional_file)
ast_unique_key_for_additional_file: []const []const u8,
/// Only safe to use when outside the bundler. As in, the import records
/// were not resolved to source indices. This will out-of-bounds otherwise.
pub fn initOutsideOfBundler(records: *bun.BabyList(bun.ImportRecord)) ImportInfo {
return .{
.import_records = records,
.ast_urls_for_css = &.{},
.ast_unique_key_for_additional_file = &.{},
};
}
};
/// A `Printer` represents a destination to output serialized CSS, as used in
/// the [ToCss](super::traits::ToCss) trait. It can wrap any destination that
/// implements [std::fmt::Write](std::fmt::Write), such as a [String](String).
///
/// A `Printer` keeps track of the current line and column position, and uses
/// this to generate a source map if provided in the options.
///
/// `Printer` also includes helper functions that assist with writing output
/// that respects options such as `minify`, and `css_modules`.
pub fn Printer(comptime Writer: type) type {
return struct {
// #[cfg(feature = "sourcemap")]
sources: ?*const ArrayList([]const u8),
dest: Writer,
loc: Location = Location{
.source_index = 0,
.line = 0,
.column = 1,
},
indent_amt: u8 = 0,
line: u32 = 0,
col: u32 = 0,
minify: bool,
targets: Targets,
vendor_prefix: css.VendorPrefix = .{},
in_calc: bool = false,
css_module: ?css.CssModule = null,
dependencies: ?ArrayList(css.Dependency) = null,
remove_imports: bool,
/// A mapping of pseudo classes to replace with class names that can be applied
/// from JavaScript. Useful for polyfills, for example.
pseudo_classes: ?PseudoClasses = null,
indentation_buf: std.ArrayList(u8),
ctx: ?*const css.StyleContext = null,
scratchbuf: std.ArrayList(u8),
error_kind: ?css.PrinterError = null,
import_info: ?ImportInfo = null,
public_path: []const u8,
symbols: *const bun.JSAst.Symbol.Map,
local_names: ?*const css.LocalsResultsMap = null,
/// NOTE This should be the same mimalloc heap arena allocator
allocator: Allocator,
// TODO: finish the fields
pub threadlocal var in_debug_fmt: if (bun.Environment.isDebug) bool else u0 = if (bun.Environment.isDebug) false else 0;
const This = @This();
pub fn lookupSymbol(this: *This, ref: bun.bundle_v2.Ref) []const u8 {
const symbols = this.symbols;
const final_ref = symbols.follow(ref);
if (this.local_names) |local_names| {
if (local_names.get(final_ref)) |local_name| return local_name;
}
const original_name = symbols.get(final_ref).?.original_name;
return original_name;
}
pub fn lookupIdentOrRef(this: *This, ident: css.css_values.ident.IdentOrRef) []const u8 {
if (comptime bun.Environment.isDebug) {
if (in_debug_fmt) {
return ident.debugIdent();
}
}
if (ident.isIdent()) {
return ident.asIdent().?.v;
}
return this.lookupSymbol(ident.asRef().?);
}
inline fn getWrittenAmt(writer: Writer) usize {
return switch (Writer) {
ArrayList(u8).Writer => writer.context.self.items.len,
*bun.js_printer.BufferWriter => writer.written.len,
else => @compileError("Dunno what to do with this type yo: " ++ @typeName(Writer)),
};
}
/// Returns the current source filename that is being printed.
pub fn filename(this: *const This) []const u8 {
if (this.sources) |sources| {
if (this.loc.source_index < sources.items.len) return sources.items[this.loc.source_index];
}
return "unknown.css";
}
/// Returns whether the indent level is greater than one.
pub fn isNested(this: *const This) bool {
return this.indent_amt > 2;
}
/// Add an error related to std lib fmt errors
pub fn addFmtError(this: *This) PrintErr {
this.error_kind = css.PrinterError{
.kind = .fmt_error,
.loc = null,
};
return PrintErr.CSSPrintError;
}
pub fn addNoImportRecordError(this: *This) PrintErr {
this.error_kind = css.PrinterError{
.kind = .no_import_records,
.loc = null,
};
return PrintErr.CSSPrintError;
}
pub fn addInvalidCssModulesPatternInGridError(this: *This) PrintErr {
this.error_kind = css.PrinterError{
.kind = .invalid_css_modules_pattern_in_grid,
.loc = css.ErrorLocation{
.filename = this.filename(),
.line = this.loc.line,
.column = this.loc.column,
},
};
return PrintErr.CSSPrintError;
}
/// Returns an error of the given kind at the provided location in the current source file.
pub fn newError(
this: *This,
kind: css.PrinterErrorKind,
maybe_loc: ?css.dependencies.Location,
) PrintErr!void {
bun.debugAssert(this.error_kind == null);
this.error_kind = css.PrinterError{
.kind = kind,
.loc = if (maybe_loc) |loc| css.ErrorLocation{
.filename = this.filename(),
.line = loc.line - 1,
.column = loc.column,
} else null,
};
return PrintErr.CSSPrintError;
}
pub fn deinit(this: *This) void {
this.scratchbuf.deinit();
this.indentation_buf.deinit();
if (this.dependencies) |*dependencies| {
dependencies.deinit(this.allocator);
}
}
/// If `import_records` is null, then the printer will error when it encounters code that relies on import records (urls())
pub fn new(
allocator: Allocator,
scratchbuf: std.ArrayList(u8),
dest: Writer,
options: PrinterOptions,
import_info: ?ImportInfo,
local_names: ?*const css.LocalsResultsMap,
symbols: *const bun.JSAst.Symbol.Map,
) This {
return .{
.sources = null,
.dest = dest,
.minify = options.minify,
.targets = options.targets,
.dependencies = if (options.analyze_dependencies != null) .empty else null,
.remove_imports = options.analyze_dependencies != null and options.analyze_dependencies.?.remove_imports,
.pseudo_classes = options.pseudo_classes,
.indentation_buf = .init(allocator),
.import_info = import_info,
.scratchbuf = scratchbuf,
.allocator = allocator,
.public_path = options.public_path,
.local_names = local_names,
.loc = .{
.source_index = 0,
.line = 0,
.column = 1,
},
.symbols = symbols,
};
}
pub inline fn getImportRecords(this: *This) PrintErr!*const bun.BabyList(bun.ImportRecord) {
if (this.import_info) |info| return info.import_records;
return this.addNoImportRecordError();
}
pub fn printImportRecord(this: *This, import_record_idx: u32) PrintErr!void {
if (this.import_info) |info| {
const import_record = info.import_records.at(import_record_idx);
const a, const b = bun.bundle_v2.cheapPrefixNormalizer(this.public_path, import_record.path.text);
try this.writeStr(a);
try this.writeStr(b);
return;
}
return this.addNoImportRecordError();
}
pub inline fn importRecord(this: *Printer(Writer), import_record_idx: u32) PrintErr!*const bun.ImportRecord {
if (this.import_info) |info| return info.import_records.at(import_record_idx);
return this.addNoImportRecordError();
}
pub inline fn getImportRecordUrl(this: *This, import_record_idx: u32) PrintErr![]const u8 {
const import_info = this.import_info orelse return this.addNoImportRecordError();
const record = import_info.import_records.at(import_record_idx);
if (record.source_index.isValid()) {
// It has an inlined url for CSS
const urls_for_css = import_info.ast_urls_for_css[record.source_index.get()];
if (urls_for_css.len > 0) {
return urls_for_css;
}
// It is a chunk URL
const unique_key_for_additional_file = import_info.ast_unique_key_for_additional_file[record.source_index.get()];
if (unique_key_for_additional_file.len > 0) {
return unique_key_for_additional_file;
}
}
// External URL stays as-is
return record.path.text;
}
pub fn context(this: *const Printer(Writer)) ?*const css.StyleContext {
return this.ctx;
}
/// To satisfy io.Writer interface
///
/// NOTE: Same constraints as `writeStr`, the `str` param is assumted to not
/// contain any newline characters
pub fn writeAll(this: *This, str: []const u8) !void {
return this.writeStr(str) catch std.mem.Allocator.Error.OutOfMemory;
}
pub fn writeComment(this: *This, comment: []const u8) PrintErr!void {
_ = this.dest.writeAll(comment) catch {
return this.addFmtError();
};
const new_lines = std.mem.count(u8, comment, "\n");
this.line += @intCast(new_lines);
this.col = 0;
const last_line_start = comment.len - (std.mem.lastIndexOfScalar(u8, comment, '\n') orelse comment.len);
this.col += @intCast(last_line_start);
return;
}
/// Writes a raw string to the underlying destination.
///
/// NOTE: Is is assumed that the string does not contain any newline characters.
/// If such a string is written, it will break source maps.
pub fn writeStr(this: *This, s: []const u8) PrintErr!void {
if (comptime bun.Environment.isDebug) {
bun.assert(std.mem.indexOfScalar(u8, s, '\n') == null);
}
this.col += @intCast(s.len);
_ = this.dest.writeAll(s) catch {
return this.addFmtError();
};
return;
}
/// Writes a formatted string to the underlying destination.
///
/// NOTE: Is is assumed that the formatted string does not contain any newline characters.
/// If such a string is written, it will break source maps.
pub fn writeFmt(this: *This, comptime fmt: []const u8, args: anytype) PrintErr!void {
// assuming the writer comes from an ArrayList
const start: usize = getWrittenAmt(this.dest);
this.dest.print(fmt, args) catch bun.outOfMemory();
const written = getWrittenAmt(this.dest) - start;
this.col += @intCast(written);
}
fn replaceDots(allocator: Allocator, s: []const u8) []const u8 {
var str = allocator.dupe(u8, s) catch bun.outOfMemory();
std.mem.replaceScalar(u8, str[0..], '.', '-');
return str;
}
pub fn writeIdentOrRef(this: *This, ident: css.css_values.ident.IdentOrRef, handle_css_module: bool) PrintErr!void {
if (!handle_css_module) {
if (ident.asIdent()) |identifier| {
return css.serializer.serializeIdentifier(identifier.v, this) catch return this.addFmtError();
} else {
const ref = ident.asRef().?;
const symbol = this.symbols.get(ref) orelse return this.addFmtError();
return css.serializer.serializeIdentifier(symbol.original_name, this) catch return this.addFmtError();
}
}
const str = this.lookupIdentOrRef(ident);
return css.serializer.serializeIdentifier(str, this) catch return this.addFmtError();
}
/// Writes a CSS identifier to the underlying destination, escaping it
/// as appropriate. If the `css_modules` option was enabled, then a hash
/// is added, and the mapping is added to the CSS module.
pub fn writeIdent(this: *This, ident: []const u8, handle_css_module: bool) PrintErr!void {
if (handle_css_module) {
if (this.css_module) |*css_module| {
const Closure = struct { first: bool, printer: *This };
var closure = Closure{ .first = true, .printer = this };
css_module.config.pattern.write(
css_module.hashes.items[this.loc.source_index],
css_module.sources.items[this.loc.source_index],
ident,
&closure,
struct {
pub fn writeFn(self: *Closure, s1: []const u8, replace_dots: bool) void {
// PERF: stack fallback?
const s = if (!replace_dots) s1 else replaceDots(self.printer.allocator, s1);
defer if (replace_dots) self.printer.allocator.free(s);
self.printer.col += @intCast(s.len);
if (self.first) {
self.first = false;
return css.serializer.serializeIdentifier(s, self.printer) catch |e| css.OOM(e);
} else {
return css.serializer.serializeName(s, self.printer) catch |e| css.OOM(e);
}
}
}.writeFn,
);
css_module.addLocal(this.allocator, ident, ident, this.loc.source_index);
return;
}
}
return css.serializer.serializeIdentifier(ident, this) catch return this.addFmtError();
}
pub fn writeDashedIdent(this: *This, ident: *const DashedIdent, is_declaration: bool) !void {
try this.writeStr("--");
if (this.css_module) |*css_module| {
if (css_module.config.dashed_idents) {
const Fn = struct {
pub fn writeFn(self: *This, s1: []const u8, replace_dots: bool) void {
const s = if (!replace_dots) s1 else replaceDots(self.allocator, s1);
defer if (replace_dots) self.allocator.free(s);
self.col += @intCast(s.len);
return css.serializer.serializeName(s, self) catch |e| css.OOM(e);
}
};
css_module.config.pattern.write(
css_module.hashes.items[this.loc.source_index],
css_module.sources.items[this.loc.source_index],
ident.v[2..],
this,
Fn.writeFn,
);
if (is_declaration) {
css_module.addDashed(this.allocator, ident.v, this.loc.source_index);
}
}
}
return css.serializer.serializeName(ident.v[2..], this) catch return this.addFmtError();
}
pub fn writeByte(this: *This, char: u8) !void {
return this.writeChar(char) catch return Allocator.Error.OutOfMemory;
}
/// Write a single character to the underlying destination.
pub fn writeChar(this: *This, char: u8) PrintErr!void {
if (char == '\n') {
this.line += 1;
this.col = 0;
} else {
this.col += 1;
}
_ = this.dest.writeByte(char) catch {
return this.addFmtError();
};
}
/// Writes a newline character followed by indentation.
/// If the `minify` option is enabled, then nothing is printed.
pub fn newline(this: *This) PrintErr!void {
if (this.minify) {
return;
}
try this.writeChar('\n');
return this.writeIndent();
}
/// Writes a delimiter character, followed by whitespace (depending on the `minify` option).
/// If `ws_before` is true, then whitespace is also written before the delimiter.
pub fn delim(this: *This, delim_: u8, ws_before: bool) PrintErr!void {
if (ws_before) {
try this.whitespace();
}
try this.writeChar(delim_);
return this.whitespace();
}
/// Writes a single whitespace character, unless the `minify` option is enabled.
///
/// Use `write_char` instead if you wish to force a space character to be written,
/// regardless of the `minify` option.
pub fn whitespace(this: *This) PrintErr!void {
if (this.minify) return;
return this.writeChar(' ');
}
pub fn withContext(
this: *This,
selectors: *const css.SelectorList,
closure: anytype,
comptime func: anytype,
) PrintErr!void {
const parent = if (this.ctx) |ctx| parent: {
this.ctx = null;
break :parent ctx;
} else null;
const ctx = css.StyleContext{ .selectors = selectors, .parent = parent };
this.ctx = &ctx;
const res = func(closure, Writer, this);
this.ctx = parent;
return res;
}
pub fn withClearedContext(
this: *This,
closure: anytype,
comptime func: anytype,
) PrintErr!void {
const parent = if (this.ctx) |ctx| parent: {
this.ctx = null;
break :parent ctx;
} else null;
const res = func(closure, Writer, this);
this.ctx = parent;
return res;
}
/// Increases the current indent level.
pub fn indent(this: *This) void {
this.indent_amt += 2;
}
/// Decreases the current indent level.
pub fn dedent(this: *This) void {
this.indent_amt -= 2;
}
const INDENTS: []const []const u8 = indents: {
const levels = 32;
var indents: [levels][]const u8 = undefined;
for (0..levels) |i| {
const n = i * 2;
var str: [n]u8 = undefined;
for (0..n) |j| {
str[j] = ' ';
}
indents[i] = str;
}
break :indents indents;
};
fn getIndent(this: *This, idnt: u8) []const u8 {
// divide by 2 to get index into table
const i = idnt >> 1;
// PERF: may be faster to just do `i < (IDENTS.len - 1) * 2` (e.g. 62 if IDENTS.len == 32) here
if (i < INDENTS.len) {
return INDENTS[i];
}
if (this.indentation_buf.items.len < idnt) {
this.indentation_buf.appendNTimes(' ', this.indentation_buf.items.len - idnt) catch unreachable;
} else {
this.indentation_buf.items = this.indentation_buf.items[0..idnt];
}
return this.indentation_buf.items;
}
fn writeIndent(this: *This) PrintErr!void {
bun.debugAssert(!this.minify);
if (this.indent_amt > 0) {
// try this.writeStr(this.getIndent(this.ident));
this.dest.writeByteNTimes(' ', this.indent_amt) catch return this.addFmtError();
}
}
};
}