Skip to content

fix: parse scientific notation in numbers - #4490

Open
Jaybhade wants to merge 1 commit into
less:masterfrom
Jaybhade:fix/dimension-exponent
Open

fix: parse scientific notation in numbers#4490
Jaybhade wants to merge 1 commit into
less:masterfrom
Jaybhade:fix/dimension-exponent

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 9, 2026

Copy link
Copy Markdown

What: Teach the dimension parser rule about the exponent part of a CSS number, so 1e3px, .5e-2px and 2e+2px compile to the value they mean.

Why: CSS numbers may carry an exponent — css-syntax-3 §4.3.12 defines <number-token> as [+-]? (\d+ | \d*\.\d+) ([eE][+-]?\d+)?, and a <dimension-token> is that number followed by an ident. The rule at packages/less/lib/less/parser/parser.js matched the number without the exponent:

/^([+-]?\d*\.?\d+)(%|[a-z_]+)?/i

so in .5e-2px it took .5 as the number and e as the unit, leaving -2px to be parsed as a separate term. The two then combine, and the result is neither an error nor the right value:

input 4.8.1 this PR Chrome / Safari
padding: .5e-2px -1.5e 0.005px 0.005px
margin: 2e+2px 4e 200px 200px
padding: 5e-1em 4e 0.5em 0.5em
opacity: 1e-1 0e 0.1 0.1
transform: scale(1e-2) scale(-1e) scale(0.01) scale(0.01)
flex-basis: 1.5e2% 1.5e 2% 150% 150%
min-width: calc(1e3px + 1px) calc(1e 3px + 1px) calc(1000px + 1px)
width: (1e3px + 1px) ParseError: Expected ')' 1001px

The last column is el.style.width after assigning each value, read back from Chromium 133 and WebKit 18.2.

A bare literal declaration such as width: 1e3px; happens to survive today because it takes the verbatim fast path for simple values and is never parsed as a dimension. That protection disappears as soon as the value meets any Less feature, which is what makes this easy to miss:

@size: 1e2px;
.a { width: @size; }        // 4.8.1: width: 1e 2px
.b when (1e2px > 50px) { }  // 4.8.1: ParseError: expected condition
@media (min-width: 1e3px) { } // 4.8.1: @media (min-width: 1e 3px)

Exponents mostly reach Less from generated or minified CSS rather than from hand-written source, so the failure tends to show up as a rule the browser drops, or as a value that is quietly wrong, well away from the code that produced it.

The fix appends an optional (?:e[+-]?\d+)? to the number group. The exponent needs at least one digit after e, so a unit that merely begins with e still parses as a unit: 1em and 2ex are unchanged. 1e2em now means 100em, which it did not before. No unit contains a digit, and [a-z_]+ never matched one, so nothing that used to parse as a unit stops doing so.

Tests: new fixture packages/test-data/tests-unit/numbers-exponent, covering literal declarations, units that start with e, arithmetic in parens, variables, a mixin argument, a guard and an @media query. Every expected value in the .css was checked against the two browsers above. Reverting parser.js and keeping the fixture makes grunt test:node exit 6 with ERROR: Expected ')'.

pnpm test passes on this branch: All Passed 211 run, including the headless-Chrome browser suite. pnpm --filter less typecheck is clean. pnpm lint reports one pre-existing parse error in benchmark/benchmark-runner.js that is present on master and is unrelated to this change (#4453 appears to cover it).

Checklist:

  • Documentation — N/A, no documented behaviour changes
  • Added/updated unit tests
  • Code complete

Summary by CodeRabbit

  • New Features

    • Added support for scientific-notation dimension values, such as 1e3px.
    • Preserved correct handling of units beginning with “e,” including em and ex.
  • Bug Fixes

    • Improved exponent-number parsing across calculations, variables, mixins, guarded rules, and media queries.
  • Tests

    • Added comprehensive coverage for exponent notation and related unit-handling scenarios.

The dimension rule matched a number without an exponent part, so the `e`
in `1e3px` was taken as the unit and the rest as a separate value. Valid
CSS was silently compiled to a different value — `scale(1e-2)` became
`scale(-1e)` and `padding: .5e-2px` became `-1.5e` — or to output that is
not CSS at all, and `(1e3px + 1px)` failed to parse.

The exponent requires at least one digit after `e`, so units that begin
with `e` keep parsing as units: `1em` and `2ex` are unchanged, while
`1e2em` is now 100em.
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e4279db4-901c-4d50-ad0a-2823a0f1b8c2

📥 Commits

Reviewing files that changed from the base of the PR and between c303718 and 0f99a40.

📒 Files selected for processing (3)
  • packages/less/lib/less/parser/parser.js
  • packages/test-data/tests-unit/numbers-exponent/numbers-exponent.css
  • packages/test-data/tests-unit/numbers-exponent/numbers-exponent.less

📝 Walkthrough

Walkthrough

The dimension parser now accepts scientific-notation numbers such as 1e3px. Test fixtures cover literals, e-prefixed units, arithmetic, variables, mixins, guards, and media queries.

Changes

Scientific-Notation Dimension Parsing

Layer / File(s) Summary
Exponent-aware dimension grammar
packages/less/lib/less/parser/parser.js
The dimension token grammar accepts optional signed exponents followed by digits.
Literal and expression coverage
packages/test-data/tests-unit/numbers-exponent/numbers-exponent.less, packages/test-data/tests-unit/numbers-exponent/numbers-exponent.css
Fixtures cover exponent-form literals, em and ex units, and arithmetic results.
Contextual evaluation coverage
packages/test-data/tests-unit/numbers-exponent/numbers-exponent.less, packages/test-data/tests-unit/numbers-exponent/numbers-exponent.css
Fixtures cover variables, mixin arguments, guarded rules, and media-query conditions.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: parsing scientific notation in numbers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented Aug 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR extends Less dimension parsing to recognize CSS scientific notation and adds automatically discovered fixtures covering declarations, arithmetic, variables, mixins, guards, and media queries.

  • Adds optional signed exponent parsing to the dimension rule.
  • Verifies exponent handling across unitless and unit-bearing values.
  • Preserves common units beginning with e, such as em and ex.

Confidence Score: 4/5

The PR should not merge until exponent overflow is prevented from producing invalid Infinity dimensions.

Ordinary scientific notation is parsed as intended, but sufficiently large accepted exponents overflow during Dimension construction and are emitted as invalid CSS.

Files Needing Attention: packages/less/lib/less/parser/parser.js

Reviews (1): Last reviewed commit: "fix: parse scientific notation in number..." | Re-trigger Greptile


const value = parserInput.$re(/^([+-]?\d*\.?\d+)(%|[a-z_]+)?/i);
const value = parserInput.$re(/^([+-]?\d*\.?\d+(?:e[+-]?\d+)?)(%|[a-z_]+)?/i);
if (value) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Exponent overflow emits Infinity

When a dimension contains a sufficiently large exponent such as 1e309px, the expanded regex passes 1e309 to Dimension, where parseFloat produces Infinity; serialization then emits invalid CSS such as Infinitypx instead of safely handling the numeric token.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The Infinitypx output is real, but it isn't reachable by the example given, and it isn't introduced here — it's a pre-existing overflow in Dimension.genCSS.

The stated repro doesn't reproduce. A declaration value isn't numerically re-serialized, so 1e309px passes through verbatim — on this branch and on master alike:

a { width: 1e309px; }   /* both:  width: 1e309px  */

Infinity appears only once something forces the Dimension back through genCSS: arithmetic, unary minus, a variable reference, or unit().

And it isn't about exponents. genCSS does String(this.fround(context, this.value)) over a value from parseFloat, so any literal past Number.MAX_VALUE (1.797e308) overflows. The defect reproduces on master with no exponent syntax anywhere — just a digit run:

input, on master output
-1 followed by 308 zeros, then px -1e+308px
-1 followed by 309 zeros, then px -Infinitypx

One extra zero flips it, and nothing from this PR is on that path.

The 308-zero row is worth a second look, because it's this PR's own argument. master emits -1e+308px — notation master's parser cannot read back. Feeding that output in again:

a { width: -1e+308px + 0px; }
/* master:  width: 307e;       ← sign, mantissa and unit all gone */
/* branch:  width: -1e+308px;  */

So the exponent gap was never only an input problem: less already produces exponent notation and then fails to round-trip it. That's the hole this change closes.

On fixing the overflow here — I'd rather not, and I don't think it belongs in this PR. The honest fix lives in Dimension.genCSS (reject or clamp a non-finite value), and it would change master's behaviour for the plain digit-run case above, which has nothing to do with parsing scientific notation. It's also a real decision rather than a mechanical one: throw "number too large" versus clamp to MAX_VALUE. Turning a one-line regex change into a numeric-output change seems like the wrong trade. Happy to file it separately and fix it there — just say which behaviour you'd want.

All seven test legs are green on 0f99a40.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right on all counts. The overflow predates this PR, isn't introduced by the regex change, and the round-trip argument you've made is actually a stronger case for this fix — master already emits notation it can't re-parse.

The original concern doesn't hold up against the repro evidence. A separate issue/PR scoped to Dimension.genCSS is the right place to decide between throwing and clamping for non-finite values. That decision shouldn't block a correct parser fix.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@matthew-dean

Copy link
Copy Markdown
Member

@coderabbitai - thoughts on this discussion here? #4490 (review) Is this a correct change? How does this align with other CSS pre-processors?

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="less/less.js"
pr="4490"
review_id="4890810872"

echo "== Pull request metadata =="
gh api "repos/$repo/pulls/$pr" \
  --jq '{number, title, state, base: .base.ref, head: .head.ref, body}'

echo
echo "== Referenced review =="
gh api "repos/$repo/pulls/$pr/reviews/$review_id" \
  --jq '{id, user: .user.login, state, body, submitted_at, commit_id}'

echo
echo "== Referenced review comments =="
gh api --paginate "repos/$repo/pulls/$pr/comments?per_page=100" \
  --jq '.[] | select(.pull_request_review_id == 4890810872) | {id, path, line, side, body, diff_hunk, user: .user.login}'

echo
echo "== Parser structure =="
ast-grep outline packages/less/lib/less/parser/parser.js --items all --type function --match 'dimension|entities'

echo
echo "== Affected parser context =="
rg -n -C 16 'dimension\s*[:=]|dimension\s*\(' packages/less/lib/less/parser/parser.js

echo
echo "== Exponent fixture files =="
fd -t f '^numbers-exponent\.(less|css)$' packages/test-data/tests-unit -x sh -c '
  echo "---- $1 ----"
  cat -n "$1"
' sh {}

Length of output: 14899


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Dimension implementation candidates =="
fd -t f . packages/less/lib/less/tree | rg '/dimension\.(js|ts)$|/unit\.(js|ts)$'

echo
echo "== Dimension implementation outline and relevant code =="
fd -t f '^dimension\.(js|ts)$' packages/less/lib/less/tree -x sh -c '
  ast-grep outline "$1" --items all
  echo "---- $1: Infinity, parseFloat, toCSS, value ----"
  rg -n -C 10 "parseFloat|Infinity|NaN|toCSS|value" "$1"
' sh {}

echo
echo "== Numeric serialization and finite-value checks =="
rg -n -C 6 'isFinite|Number\.isFinite|Infinity|NaN|parseFloat|toPrecision|toFixed' \
  packages/less/lib packages/test-data/tests-unit \
  -g '*.js' -g '*.less' -g '*.css'

echo
echo "== Existing scientific-notation and large-number tests =="
rg -n -i -C 5 'scientific|exponent|1e[+-]?[0-9]+|e[+-]?[0-9]+|infinity|nan|[0-9]{100,}' \
  packages/test-data packages/less \
  -g '*.less' -g '*.css' -g '*.js'

Length of output: 201460


🤖 web_search executed:

Dart Sass scientific notation number literal support exponent documentation source 1e3``

Length of output: 7647


🤖 web_search executed:

Stylus scientific notation numeric literal exponent 1e3 support source documentation

Length of output: 6466


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Less Dimension source =="
cat -n packages/less/lib/less/tree/dimension.js

echo
echo "== Less number formatting source =="
rg -n -C 12 'numPrecision|toFixed|toPrecision|Number\.isFinite|isFinite|Infinity|NaN' \
  packages/less/lib/less \
  -g '*.js' \
  -g '!**/tree/dimension.js'

echo
echo "== Base and PR parser rules =="
git show "origin/master:packages/less/lib/less/parser/parser.js" \
  | rg -n -C 8 'dimension: function|const value = parserInput\.\$re'
printf '\n-- PR branch --\n'
sed -n '906,924p' packages/less/lib/less/parser/parser.js

echo
echo "== Existing large-decimal parsing path =="
rg -n -C 8 'new\(tree\.Dimension\)|new tree\.Dimension|parseFloat' \
  packages/less/lib/less \
  -g '*.js'

echo
echo "== Stylus lexer source candidates =="
stylus_tree="$(mktemp)"
gh api "repos/stylus/stylus/git/trees/dev?recursive=1" > "$stylus_tree"
jq -r '.tree[] | select(.type == "blob") | .path' "$stylus_tree" \
  | rg '(^|/)(lexer|parser|token|nodes)/.*\.(js|ts)$|^lib/.*\.(js|ts)$' \
  | head -80

echo
echo "== Stylus source references to numeric exponent lexing =="
jq -r '.tree[] | select(.type == "blob") | .path' "$stylus_tree" \
  | rg '\.(js|ts)$' \
  | while IFS= read -r path; do
      content="$(gh api "repos/stylus/stylus/contents/$path?ref=dev" --jq .content 2>/dev/null | base64 -d 2>/dev/null || true)"
      if printf '%s' "$content" | rg -q 'unit|[eE]\[|[eE].*\\d|exponent'; then
        printf '\n---- %s ----\n' "$path"
        printf '%s\n' "$content" | rg -n -i -C 4 'unit|exponent|\\d\+|\\d\*|number'
      fi
    done

Length of output: 29817


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Stylus lexer number-token rule =="
gh api "repos/stylus/stylus/contents/lib/lexer.js?ref=dev" --jq .content \
  | base64 -d \
  | tee /tmp/stylus-lexer.js \
  | rg -n -i -C 12 'unit|number|numeric|float|[eE].*\\d'

echo
echo "== Stylus lexer test coverage for exponent literals =="
tree_json="$(mktemp)"
gh api "repos/stylus/stylus/git/trees/dev?recursive=1" > "$tree_json"
jq -r '.tree[] | select(.type == "blob") | .path' "$tree_json" \
  | rg '(^|/)(test|tests)/.*\.(styl|css|js)$' \
  | while IFS= read -r path; do
      content="$(gh api "repos/stylus/stylus/contents/$path?ref=dev" --jq .content 2>/dev/null | base64 -d 2>/dev/null || true)"
      if printf '%s' "$content" | rg -qP '(?<![[:alnum:]_])[0-9]+(?:\.[0-9]*)?[eE][+-]?[0-9]+'; then
        printf '\n---- %s ----\n' "$path"
        printf '%s\n' "$content" | rg -n -P -C 3 '(?<![[:alnum:]_])[0-9]+(?:\.[0-9]*)?[eE][+-]?[0-9]+'
      fi
    done

Length of output: 1333


🏁 Script executed:

#!/bin/bash
set -euo pipefail

lexer_file="/tmp/stylus-lexer.js"
gh api "repos/stylus/stylus/contents/lib/lexer.js?ref=dev" --jq .content \
  | base64 -d > "$lexer_file"

echo "== Numeric lexer rules and unit handling =="
rg -n -i -C 12 'unit|number|numeric|float|[eE].*\\d' "$lexer_file"

echo
echo "== Full candidate methods =="
ast-grep outline "$lexer_file" --items all --type method,function --match 'unit|number'

Length of output: 4247


@matthew-dean: I think the parser change is correct.

(?:e[+-]?\d+)? adds the exponent syntax required by the CSS number-token grammar. It requires a digit after e. Therefore, it does not consume the e in 1em or 2ex. It correctly changes 1e2em to a number token with value 100 and unit em.

The bot comment is technically correct about this result:

.a { width: (1e309px); }
// can emit: Infinitypx

However, it is not a regression from this PR. packages/less/lib/less/tree/dimension.js already calls parseFloat() and rejects only NaN. The base parser already accepts an arbitrarily long decimal literal and sends it through that same path. A sufficiently large literal such as a 400-digit 9...9px can therefore already become Infinitypx.

I would withdraw the P1 finding. The PR newly makes exponent tokens parse correctly. It does not introduce the underlying non-finite-number serialization behavior.

A Number.isFinite() check could still be useful. It needs a separate policy decision:

  • Reject non-finite source dimensions with a Less error.
  • Preserve an untouched CSS token where evaluation is not required.
  • Define behavior for non-finite values produced by Less arithmetic or JavaScript evaluation.

That change would affect more than this exponent parser rule. It should not block this focused compatibility fix.

This aligns with other preprocessors as follows:

  • Sass supports scientific notation as a CSS number format. It expands evaluated values in output. For example, Sass documents 5.2e3 as 5200 and 6e-2 as 0.06.
  • Stylus does not currently support exponent syntax in its lexer. Its unit() expression accepts only integer or decimal mantissas. Its behavior is therefore similar to Less before this PR.
  • Less after this PR will match CSS tokenization and Sass for valid finite exponent values. The fixture coverage for declarations, calculations, variables, mixin arguments, guards, and media queries is appropriate.

I would merge this PR after normal test validation. I would track non-finite Dimension values separately if the project wants to reject or handle them consistently.

You are interacting with an AI system.

@Jaybhade

Jaybhade commented Sep 2, 2026

Copy link
Copy Markdown
Author

@matthew-dean — happy to answer both directly.

Is it correct? The change adds one optional group, (?:e[+-]?\d+)?, which is exactly the exponent part of <number-token> in css-syntax-3 §4.3.12. It requires at least one digit after the e, so units that merely start with e are unaffected — the unit branch [a-z_]+ never matched a digit, and no CSS unit contains one.

How it aligns. I ran the same values through Sass 1.103.1, Stylus 0.64.0 and Less on both branches, each one through a variable so nothing takes the verbatim-declaration fast path:

input Sass Stylus less master this PR
transform: scale(1e-2) scale(0.01) scale(1e -2) scale(-1e) scale(0.01)
opacity: 1e-1 0.1 1e -1 0e 0.1
padding: .5e-2px 0.005px 0.5e -2px -1.5e 0.005px
margin: 2e+2px 200px 4e 4e 200px
flex-basis: 1.5e2% 150% 1.5e 2% 1.5e 2% 150%
width: 1em 1em 1em 1em 1em
width: 2ex 2ex 2ex 2ex 2ex
width: 1e2em 100em 1e 2em 1e 2em 100em

Two things worth pulling out of that:

  • Sass agrees with this PR on every row, and so do Chrome and WebKit for the same declarations (that table is in the PR description) — so the target value isn't my reading of the spec.
  • Stylus isn't a counter-example. It doesn't decline to support exponents by design; it produces the same corruption Less does, identically, on the last four rows — 2e+2px4e in both. Two lexers landing on the same mangling is the same gap, not an alternative convention.

The bottom three rows are the guard against the obvious risk: 1em and 2ex are untouched, and 1e2em resolves to 100em in both Sass and this PR.

On the P1 finding, I'd still keep it out of this PR. Infinitypx is real but it isn't exponent-specific and isn't introduced here: on master today, -1 followed by 309 zeros and px already serializes as -Infinitypx with no e anywhere, because Dimension.genCSS stringifies a parseFloat value and only rejects NaN. Deciding between throwing and clamping to MAX_VALUE changes existing behaviour for non-exponent literals, so it wants its own issue — glad to open one.

CI is green on all seven test jobs at 0f99a40.

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

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants