Skip to content

css: log parser warnings with the Source and a byte position - #45787

Open
robobun wants to merge 3 commits into
mainfrom
robobun/2181133b/css-warning-source-location
Open

robobun wants to merge 3 commits into
mainfrom
robobun/2181133b/css-warning-source-location

Conversation

@robobun

@robobun robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #45554 (the first two commits). Merge it first.

Problem

  • bun build ./a.css prints warn: invalid @ rule encountered: '@unknown-rule' with no at file:line:column. In Bun.build logs position.file and lineText are "". No user reported this.
  • ParserOptions::warn* (src/css/css_parser.rs:2909) logs through Log::add_warning_fmt_line_col, which takes a line, a column and no Source.

Fix

  • The CSS parse entry points take the Source. Each warn site passes a byte position, and the logger builds file, 1-based line, column and lineText.
  • New Log::add_warning_fmt_no_long_excerpt drops an excerpt of 120 bytes or more and keeps the place.
  • The nested composes warning moves to the declaration.
  • Verified: test/bundler/css/css-modules.test.ts (19 new tests fail on main), test/bake/dev/css.test.ts. Self-reviewed: 16 concerns raised, 15 addressed. Rejected: a row pinning a false composes warning (css modules: keep composes working after a nested rule in the same block #38520).

Background

Downsides

  • More output: 1,000 warnings on short lines print 4,999 lines, was 1,999.
  • No lineText on lines of 120 bytes or more. CSS errors keep their own positions.
  • Per warning: 29 allocator calls, was 18. ParserOptions: 128 bytes, was 120. Release text: +256 bytes.
Notes

Before and after (.a { color: red }\n.b { color: blue }\n@unknown-rule x;\n, Bun.build logs):

main  warns.css         file ""                         line 2  lineText ""
main  warns.module.css  file "warns.module.css"         line 2  lineText ""
PR    warns.css         file "/tmp/x/warns.css"         line 3  lineText "@unknown-rule x;"
PR    warns.module.css  file "/tmp/x/warns.module.css"  line 3  lineText "@unknown-rule x;"
$ bun build ./warns.css
3 | @unknown-rule x;
                 ^
warn: invalid @ rule encountered: '@unknown-rule'
   at /tmp/x/warns.css:3:14

The threads that quote these warnings (#12878, #20619) are about Tailwind directives, and this PR prints more lines for each such warning.

Why it is stacked. The logger's LineColumnTracker scans from byte 0 for a position that is a UTF-8 continuation byte. CSS warnings do not use the logger's scanner on main. With this change alone, 20,000 warnings on stray continuation bytes in a 400 KB sheet take 5.5 s (main: 0.09 s). #45554 removes that scan and also counts columns from 1 after a lone CR, which the CSS tokenizer treats as a line break. On top of #45554 the same sheet takes 83 ms (77 to 85 ms in 5 runs, base 69 to 94 ms). The first two commits here are #45554, cherry-picked onto main. When #45554 merges, a rebase leaves one commit.

error_recovery stays. ParserOptions.error_recovery and its five readers are not changed, and warn(&ParseError) keeps its name: the draft #44833 sets the option. A ParseError has a line and a column and no byte position, so a warning for an error that the option skips has the file and no line. Nothing sets the option on main, so no test reaches that function. The warn function with a byte position is warn_at.

Warn sites. Unknown at-rule, @nest, the four unsupported pseudo-class and pseudo-element sites, the two composes warnings and the composes note. The texts do not change. The simple pseudo-class parser gets the byte offset as one more argument. A declaration no longer reads current_source_location().

Producers. ParseTask (bundler, dev server, HTML entries), Transpiler::build_css_output (bun build --no-bundle), cssInternals, bun pm diff. ParserOptions.source is private and the parse entries set it, so a producer cannot log a CSS warning without its sheet.

Long lines. Location::init_or_null (src/ast/lib.rs) keeps the whole line when the position is in its last 80 bytes. Otherwise it keeps 40 bytes before the position and 80 after. Location does not record where a cut starts, so column does not index a cut lineText, and width_before pads the caret by the whole column. Without the new entry a one-line sheet with 100, 200 and 400 warnings prints 197, 734 and 2,860 KB. The entry drops an excerpt of 120 bytes or more: every excerpt that is cut on the left is at least that long, and a shorter one starts where its line starts. With it the same sheets print 10.0, 19.9 and 39.8 KB (main: 5.1, 10.2 and 20.4 KB, with no place). The entry is add_warning_fmt_no_excerpt with that one exception. It stays until the logger itself stores a line whole up to a bound and nothing of a longer line, which is the rule bun check already has (MAX_SHOWN_LINE, src/sema/driver/lib.rs:1226). #41658 records the start column of a cut and does not bound the whole-line case.

composes. The "not allowed inside nested selectors" warning was at the start of a rule. For .outer { @media (min-width: 1px) { composes: base } } that was the start of .outer. It is now at the declaration, as the "single class selectors" warning already is. ComposesState::Allow and DisallowNested no longer carry a position. Both composes warnings are still followed by error: Failed to generate CSS for this file (PrintError) with only the file name (#38538 removes that error).

Dev server. A CSS warning on line 1 had line 0. The overlay client reads line 0 as "no position" (src/runtime/bake/client/error-serialization.ts:70), while the server writes the rest of the position for every line that is not negative (src/runtime/bake/dev_server/serialized_failure.rs:208), so the client fails to decode the payload. The new test in test/bake/dev/css.test.ts fails that way on main. This PR covers the CSS producer only. Two other producers still send line 0 (src/ast_jsc/lib.rs:30, src/bundler/ParseTask.rs:1823), and the change to the writer is in #39488.

Positions against the tokenizer. The warning has the tokenizer's line + 1 and column in 14 of 19 sheets (LF, CRLF, lone CR, first line, no final line break, tabs, comments, nesting, multi-byte characters, BOM). The 5 that differ:

end of sheet (`@unknown-rule` with no `;`)   logger 2:13   tokenizer 2:14
form feed before the warning                 logger 1:32   tokenizer 2:14
U+2028 in a comment before the warning       logger 2:19   tokenizer 1:24
U+2029 in a comment before the warning       logger 3:14   tokenizer 2:14
stray 0x80 before the warning                logger 1:42   tokenizer 1:41

The logger's line breaks are those of JavaScript. After U+2028 or U+2029 the lineText starts with the last byte of that character, as for JavaScript diagnostics (#41658 has the repair).

Measurements. Release builds, linux x64, of #45554's head be91335a6c8 (base) and of that commit with this PR's commit. perf and valgrind are not available on the machine, so instruction counts come from gdb (record full, breakpoint hit counts).

  • size_of (gdb): ParserOptions 120 to 128, ComposesState 12 to 8, NestedRuleParser<BundlerAtRuleParser> 120 to 120. ParserState 32, SourceLocation 8 and ParseError<ParserError> 48 do not change.
  • Release text (size): 106,331,715 to 106,331,971 bytes. nm -S: add_warning_fmt_line_col_with_notes -691, add_warning_fmt_no_long_excerpt +876, ParserOptions::warn_at +56, warn_with_note +75, RuleBodyParser<NestedRuleParser<BundlerAtRuleParser>>::next 5,828 to 5,418, parse_declaration_impl 1,227 to 1,140, parse_selector<Selectors> 20,775 to 20,893.
  • A whole parse with no warning (StyleSheet::parse_with, entry to return, record full): normalize.css 492,509 to 490,257 instructions, hint.css 1,579,640 to 1,572,870, random-gradients.css 967,348 to 966,590, trainer-gallery-holo.css 512,641 to 512,452.
  • Allocator calls for each warning on a short line (gdb hit counts on mi_*, sheets with 1,000 and 5,000 warnings): 18.1 on the base, 29.4 here. Each hand-over of a log copies the file and the excerpt of a message.
  • lineText bytes (base: 0): 80,000 warnings one per line, sum 3,497,780, max 44. 80,000 warnings on one line, sum 107 (the one warning in the first 40 bytes). 4,000 composes in one rule that starts in the last 80 bytes of a 260 KB line, sum 68,000, max 17: the logger copies that line for each note and the entry drops the copy.
  • CLI stderr, one warning on each short line: 1,000 warnings print 4,999 lines and 153 KB (base: 1,999 lines, 55 KB).
  • Wall time of Bun.build, median of 5 interleaved runs on a machine that was not idle: 80,000 warnings one per line 307 ms (base 275), 80,000 on one line 325 ms (base 304), 32,001 composes warnings 167 ms (base 156), the 260 KB line sheet 335 ms (base 323), 10,000 warnings on stray continuation bytes 48 ms (base 43).

Not changed.

Test harness. The CLI backend of itBundled ignores a warning with no at line and fails a test for a located warning that the test does not list. Plain-sheet CSS warnings are now located. No test on main is affected (105 bundler and CSS test files on a release build: the same results as the base).

Open PRs that need a change after this.

Question for a maintainer. .claude/docs/landing-prs.md says a rare-case fix must not add cost to the hot path. This passes one more usize through the simple pseudo-class path and removes the source location read from every style-rule declaration. Is the measured net acceptable?

…equence

An error at the end of a file with CR LF line ends was reported one line
past the last line, at column 0, with an empty line text and no code frame.
The scan stopped on the LF of the final pair, after it had read the CR.

- error_stop_offset replaces clamp_error_offset. It moves every Loc to the
  first byte of the unit that holds it: the CR of a CR LF pair, the first
  byte of a UTF-8 sequence. Location::init_or_null_impl calls it once.
- The CR arm of the scanner starts the next line at column 1, as the LF,
  U+2028 and U+2029 arms do. A file with lone CR line ends no longer has
  columns that count from 0.
- LineColumnTracker no longer rescans from byte 0 for an offset on a
  continuation byte: no scan stops inside a sequence now.
- bun_core::strings::wtf8_codepoint_start is the code_point_start of the
  lint's regex subject, moved so that the logger can call it too.
A warning of the CSS parser had no file for a plain sheet, no line text,
and a 0-based line. ParserOptions::warn* had a line and a column only,
and logged through Log::add_warning_fmt_line_col, the one logger entry
that takes no Source.

The parse entry points now take the Source, and each warn site passes
the byte offset of the tokenizer to ParserOptions::warn_at. The logger
builds the file, the namespace, the line, the column and the line text,
as it does for the diagnostics of every other parser.

Warnings go through the new Log::add_warning_fmt_no_long_excerpt. It
drops an excerpt of 120 bytes or more: the logger cuts such a line
around the position and pads the caret by the whole column, so the
warnings of a one-line sheet would print in the order of N squared
bytes.

ParserOptions::warn(&ParseError) stays for the errors that
error_recovery skips. A ParseError has no byte position, so such a
warning has the file and no line.

The warning for a nested "composes" is now at the declaration. It was at
the start of a rule, and not always of the rule that holds the
declaration.

Log::add_warning_fmt_line_col, Log::add_warning_fmt_line_col_with_notes
and SourceLocation::to_logger_location are deleted.
@robobun

robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:52 PM PT - Oct 17th, 2026

✅ @robobun, your commit 7bf7a070f6a1f33186027e09015755de621be0fe passed in Build #127709! 🎉


🧪   To try this PR locally:

bunx bun-pr 45787

That installs a local version of the PR into your bun-45787 executable, so you can run:

bun-45787 --bun

@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with Bun.build and bun build on a sheet that has @unknown-rule x; on line 3. On main the warning has position.file: "" (plain sheet), lineText: "" and line 2, and the CLI prints no at file:line:column. The 19 new tests in test/bundler/css/css-modules.test.ts fail without the last commit and pass with it.

This PR is stacked on #45554. Its first two commits are that PR.

@coderabbitai

coderabbitai Bot commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: dfc16e94-16a0-4539-9f72-e285d4516cbb
📥 Commits

Reviewing files that changed from the base of the PR and between 017b243 and 7bf7a07.

📒 Files selected for processing: 16
  • src/ast/lib.rs
  • src/bun_core/string/immutable.rs
  • src/bun_core/string/immutable/wtf8_text.rs
  • src/bundler/ParseTask.rs
  • src/bundler/transpiler.rs
  • src/css/css_parser.rs
  • src/css/declaration.rs
  • src/css/selectors/parser.rs
  • src/css_jsc/css_internals.rs
  • src/lint/regex/subject.rs
  • src/lint/regex/wtf8.rs
  • src/runtime/cli/pm_diff_normalize.rs
  • test/bake/dev/bundle.test.ts
  • test/bake/dev/css.test.ts
  • test/bundler/css/css-modules.test.ts
  • test/js/bun/transpiler/parse-error-column.test.ts
💤 Files with no reviewable changes: 1
  • src/lint/regex/wtf8.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

The change normalizes diagnostic byte offsets for line endings and UTF-8 characters. CSS parsing now uses Source values and reports warnings with source-backed locations. Tests cover diagnostic positions across line endings, file types, CSS parser cases, and CLI and runtime paths.

Changes

Diagnostic location handling

Layer / File(s) Summary
Normalize diagnostic offsets
src/ast/lib.rs, src/bun_core/string/immutable*, src/lint/regex/*, test/js/bun/transpiler/parse-error-column.test.ts, test/bake/dev/bundle.test.ts
Diagnostic position scans normalize offsets for CRLF pairs and UTF-8 code points. Carriage returns reset columns to 1. Warning logging accepts a source and locations for warnings and notes, and omits excerpts of at least 120 bytes. Tests cover normalized scan results and diagnostic positions across line endings and character boundaries.
Source-backed CSS diagnostics
src/css/css_parser.rs, src/css/declaration.rs, src/css/selectors/parser.rs, test/bundler/css/css-modules.test.ts, test/bake/dev/css.test.ts
CSS parser entry points accept Source values. Parser warnings and notes use byte locations, including composition, unsupported pseudo, deprecated @nest, and unknown at-rule warnings. Tests check warning locations, notes, line-ending cases, metadata, and CLI output.
Pass Source into CSS parsing
src/bundler/ParseTask.rs, src/bundler/transpiler.rs, src/css_jsc/css_internals.rs, src/runtime/cli/pm_diff_normalize.rs
Bundler, transpiler, CSS test helpers, and CLI normalization pass Source values to the updated CSS parser entry points.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7bf7a

The diagnostic-location change is ready to merge after normal checks; no actionable issue remains from this review.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: CSS parser warnings now use the Source and a byte position for logging.
Description check Passed The description is detailed and covers the problem, implementation, verification, tradeoffs, scope, and follow-up work. It does not use the template headings exactly, but it provides the required info…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the problem, implementation, verification, tradeoffs, scope, and follow-up work. It does not use the template headings exactly, but it provides the required information.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked: the move of code_point_start to bun_core::strings::wtf8_codepoint_start (src/bun_core/string/immutable/wtf8_text.rs) is behavior-identical for the lint regex caller, since the old end-of-text length of 1 and the new 0 both fail the > at - start test; error_stop_offset's offset - 1 cannot underflow because a two-byte line break ending at offset implies offset >= 1; and warn(&ParseError) logging at Loc::EMPTY is unreachable today because nothing sets error_recovery.

Extended reasoning...

The change reworks the diagnostics logger's offset location (src/ast/lib.rs), threads a bun_ast::Source through the CSS parser's entry points and all five producers, and replaces the line/column warning API with byte-position warn_at/warn_with_note; it touches no auth, crypto or injection surface. One nit finding is posted inline; the ruled-out items above are the concrete checks made beyond it.

Comment thread src/ast/lib.rs
Comment on lines +1985 to +1987
// When `Location::init_or_null` cuts a line on the left, it keeps 40 bytes before the
// position and 80 after it. A shorter excerpt starts where its line starts.
const CUT_EXCERPT_MIN: usize = 40 + 80;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get a second copy of the excerpt window that can silently drift from the one it mirrors. CUT_EXCERPT_MIN = 40 + 80 at src/ast/lib.rs:1987 restates the literals saturating_sub(40) and + 80 in init_or_null_impl (src/ast/lib.rs:833-835), and the doc comment repeats 80 again. If the window there changes, the drop rule here keeps cut excerpts with a misaligned caret or drops whole ones. Fix: one source of truth for the window — name the before/after sizes once next to init_or_null_impl (e.g. EXCERPT_BEFORE/EXCERPT_AFTER), use them in the window code, and derive CUT_EXCERPT_MIN from their sum.

Why this was flagged

The new entry add_warning_fmt_no_long_excerpt decides whether an excerpt was cut on the left by comparing its length with CUT_EXCERPT_MIN (src/ast/lib.rs:1998), which is hard-coded as 40 + 80 (src/ast/lib.rs:1987). The window it reasons about is built from the separate literals 40 and 80 in Location::init_or_null_impl (src/ast/lib.rs:833-835); nothing ties the two together. A later change to either literal (say 60 before the position) makes the comparison wrong: a left-cut excerpt of 140 bytes is still dropped, but a whole-line excerpt of 120..139 bytes is now dropped too, or with a smaller window a left-cut excerpt is kept and write_format pads the caret by the whole column past the end of the cut text (src/ast/lib.rs:1015-1018). On the base branch there is no such mirrored constant. REVIEW.md asks for one source of truth and named constants derived from what they describe.

Verification: nit. Triggering condition: only when someone later changes the excerpt window in Location::init_or_null_impl without also editing the mirrored constant CUT_EXCERPT_MIN (src/ast/lib.rs:1987). The window it mirrors is built from independent unnamed literals in init_or_null_impl (src/ast/lib.rs:833-835), so nothing ties the two sites together. Nothing breaks on the base or on this head.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants