Skip to content

Read a grid cell's digits in linear time, and an MSP label's kind off an own key - #53

Merged
ww-mw merged 2 commits into
mainfrom
fix-grid-number-regex-and-lookups
Oct 12, 2026
Merged

ww-mw merged 2 commits into
mainfrom
fix-grid-number-regex-and-lookups

Conversation

@ww-mw

@ww-mw ww-mw commented Oct 12, 2026

Copy link
Copy Markdown
Member

Two fixes of the kinds data-explorer-core fixed in v1.36.4, in this repo's own code: a backtracking regex a file's text reaches (mathworks/data-explorer-core#74), and a plain-object lookup a file's text can index with an inherited name (mathworks/data-explorer-core#73). One commit each. No version bump, no CHANGELOG entry, no package.json or lockfile change.

1. The Variable Editor grid's number test, and one more quadratic trim

The grid (src/webview/components/dex-matrix-grid.ts)

The grid tests every cell against NUMERIC to decide whether the whole block is right-aligned. It read a decimal as \d+\.?\d*. With no dot that is \d+\d*, and a backtracking engine tries every split of a run of digits between the two before it gives up. A cell that starts with a long run of digits and is not a number therefore costs the square of its length.

How a file reaches it. A binary dictionary holds a complex value as text, the way MATLAB writes it: <P Name="Value" Class="double" IsComplex="1" Dimension="3*4">1.0+2.0i 0.0+0.0i ...</P>. Core's parseCdata keeps each element as that text with MATLAB's .0 removed. displayElements() passes it on unchanged, matrixPayload places it, and the grid tests it when it opens. A complex element is never a number, so the first cell in canonical order is the one the grid tests. I checked this route end to end: the test below makes cells[0] the file's own digits. A text dictionary's plain-text {_type: 'cdata'} value takes the same parseCdata path; I probed that through core's DataModel.addDataSource only, and it has no test here. Whether a typed cell also reaches the grid with such text was not measured here.

Before / after (this machine, Node 24.14.1):

input to the bare pattern old new
1,000 digits + x 2.24 ms 0.08 ms
10,000 digits + x 214.9 ms 0.05 ms
100,000 digits + x 21,412 ms 0.44 ms
1,000,000 digits + x not run 4.42 ms

The same quadratic growth showed with an i, +1i or e tail in place of the x (21.0 to 21.4 s at 100,000 digits).

Through the real route, sparse_binary.sldd (MATLAB-written, from test-integration/fixtures/sparse) had dComplex's first element replaced with 50,000 digits followed by .0+2.0i. With the old pattern, opening the grid on it took 7,674 ms, and 7,540 ms with the digits followed by i. With the new pattern, each whole test, including the dictionary parse, takes 22 to 50 ms.

The rewrite is \d+(?:\.\d*)?, the same rewrite core made. The group is non-capturing, so the capture-group numbering does not change.

Equivalence, old and new, compared on test() and on every exec() group (with index). Both patterns are read from the two files (git show origin/main: and the worktree), not retyped:

population strings matched by old test() mismatches exec() mismatches
every string of ≤ 9 chars over 0 1 9 . e E + - i x and space 2,593,742,460 834,759 0 0
every string of ≤ 7 chars over those plus I n f N a 286,331,153 55,950 0 0
every sequence of ≤ 5 tokens from 1 9 . e + - i x, space, Inf NaN Infinity 12 1.5 579,195 3,411 0 0

The same harness, with a deliberately wrong rewrite swapped in, over the 235,794,769 strings of ≤ 8 chars:

mutant mismatches
digit required after the dot, \d+(?:\.\d+)? 27,981 (test() and exec())
dot dropped, \d+ 89,892 (test() and exec())
new group made capturing, \d+(\.\d*)? 0 on test(), 219,969 on exec() (every matching string)

removeToken (src/webview/rowFilter.ts)

I checked the other 28 regexes in src/ for the same failure and found one more. removeToken closes the gap left by a removed filter chip, and it trimmed the text before the chip with .replace(/\s+$/, ''). That pattern is anchored at the end only. From each character of a whitespace run that is not at the end, the engine takes the rest of the run, fails on the next character, and gives the run back one character at a time.

How it is reached: a chip's ×, Backspace into the chips, or a header popup's Clear. The text is the user's own: typed or pasted into the filter bar, or a popup value, which formatToken quotes and keeps whole, inner spaces included. I found no file-derived route.

Before / after:

  • Bare pattern, 'a' + 160,000 spaces + 'b ': 15,672 ms.
  • Removing a chip after 100,000 spaces (parse included): 8,362 to 9,380 ms before, 0 to 1 ms after.

The rewrite is trimEnd(). The spec defines both \s and the trim set as WhiteSpace plus LineTerminator. Measured agreement:

  • All code points: zero mismatches over every code point from 0 to 0x10FFFF, each tested alone and in four contexts (between letters, at the end, doubled, interleaved), plus lone surrogates. 25 of them are whitespace.
  • Short strings: zero mismatches over all 111,111,111 strings of up to 8 characters drawn from space, \t, \n, U+00A0, U+3000, U+FEFF, U+200B (which is not \s), a, " and =.
  • Wrong rewrites: the harness tells each one apart. trim() differs on 25 code-point cases, / +$/ on 96, and an ASCII-only class on 76.

The start of the gap still uses /^\s+/. It is tried at one position only, which is linear.

The regex census

Line numbers in this section are origin/main's (555de7b).

src/ holds 102 .ts files with 29 regexes: 28 literals plus one new RegExp, collected from the TypeScript AST. No string-argument .match/.search exists, and the .html files contain no inline script.

site pattern verdict
dex-matrix-grid.ts:57 NUMERIC fixed
rowFilter.ts:542 /\s+$/ fixed
rowFilter.ts:543 /^\s+/ linear: timed up to 160,000 chars
rowFilter.ts:385 /(?:[^\s"]+|"[^"]*")+/g linear: timed up to 160,000 chars on three adversarial shapes. Nothing follows the outer +, so it never backtracks into the splits
rowFilter.ts:161 sticky prefix alternation built from header labels linear: timed
rowFilter.ts:160, projectPage.ts:82 split(/\s+/) linear: timed
rowFilter.ts:237, :408 /\s+/g replace linear: timed
entrySplice.ts:160 /^[ \t]*/ linear: timed
entrySplice.ts:177 /^(\t+| +)\S/m linear: timed (tab and space runs, newline-terminated)
matrixPayload.ts:115 /^[0-9]+$/ linear: timed
BinarySlddEditorProvider.ts:914, xmlStructuralEdit.ts:41 /\n$/ no quantifier; timed
projectStore.ts:75 /\.xml$/i no quantifier; timed
pathUtil.ts:8,37,42; jsonEntryScan.ts:223; projectPage.ts:65; rowFilter.ts:141,166,176,522 (×2) single character class or literal no quantifier
dragMode.ts:17, menuItems.ts:88 user-agent alternation literals
rowFilter.ts:207, :294 /^(>=|<=|!=|~=|=|>|<)/ anchored literals

No other \d+\.?\d*-style fragment appears anywhere in the tracked tree.

2. Plain-object lookups keyed by a file's text

The three sites named

Line numbers in this section are origin/main's (555de7b).

  • src/host/mspProject.ts:83, LABEL_KIND[label.name]: reached by a file, and fixed.
    • Where the name comes from: the project store. Core's parseProject reads a label's name from its <Info Name> (XML layouts) or its TOML key.
    • What an inherited name did: toString, toLocaleString, valueOf, constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, __proto__ and the four __define/__lookup accessors each answer from Object.prototype, each truthy. So an MSP label of such a name was recorded against its id as one of the two halves.
    • Well-formed store: nothing showed. Its member was pushed as a dictionary of that "kind", and the interface-first order() dropped it.
    • Store where such a label shares a Location with InterfaceDictionary or PrivateDictionary: it overwrote that half's entry, and the half disappeared from the page. A project that lost its private half this way read as a shared interface (role: 'common') rather than a component; I checked that against the old code.
    • Can a file produce it? MATLAB does not write two labels at one Location. A monolithic Project.xml is a single document, though, and core reads both labels at one id; the test proves that precondition.
    • The fix: Object.hasOwn(LABEL_KIND, label.name) ? LABEL_KIND[label.name] : undefined, matching how #73 made core's classificationOf read own keys.
  • src/webview/components/dex-icon.ts:29, ALIASES[iconId]: not reached by a file, left alone.
    • Where iconId comes from: a row's Name.iconId (core's node.icon, all constants), the add gallery's tiles (constants in src/common/addCatalog.ts), 'wsTable', and the drag tooltip, which copies the row's.
    • What toString would do: measured in happy-dom, the component requests icons/function toString() { [native code] }.svg; constructor gives function Object() { [native code] }.svg and __proto__ gives [object Object].svg. That is a broken image, with no throw.
  • src/host/iconMap.ts:24, MAP[id]: not reached by a file, left alone.
    • Where id comes from: themeIconFor's only caller passes ICON_ID_BY_KIND[rec.kind], a closed EntryKind union. svgIconFor does no lookup.
    • What toString would do: a prediction, not run (the module imports vscode): a ThemeIcon whose id is a function.

The census

A TypeScript-checker pass over src/ found 41 computed-key reads and writes (obj[key], key in obj) on a non-array object with a string-typed key. 13 have a closed key type: a literal union or an enum-like constant. The other 28:

site key why it is left alone
mspProject.ts:83 label name fixed (above)
dex-icon.ts:29, iconMap.ts:24 icon id constants only (above)
projectStore.ts:83 out[childRel] = … resources/project/…/<name> always contains /, so it can never be an inherited name
piBuilder.ts:50 obj[propDef.name] property key toPIObject writes an own key for every property it lists: schema keys, or Other.-prefixed
jsonEntrySync.ts:177 bag[key] 'uuid', 'namespace', 'isderived' constant keys, not inherited names
BinarySlddEditorProvider.ts:182 parts[member] = data zip member name (from the file) an assignment creates an own key; see the fflate note below for __proto__
BinarySlddEditorProvider.ts:271/277/282/296 part.member, own keys the System Composer part constant, or keys from Object.keys
projectPage.ts:66 a character matched by /[&<>"]/ closed at run time
projectPage.ts:208, project-main.ts:60 state[id] section id the page's own section ids
rowFilter.ts:112, :353 labels[key] column key user-typed prefixes resolve through Maps (labelMap, SUBSTRING_FILTER_COLUMNS); only a resolved column key indexes the object
dex-tree-table.ts (12 sites: DEFAULT_WIDTHS, columnLabels, columnGroups, row[col]) column id COLUMNS/COLUMN_LABELS constants of the host

All ten in expressions in src/ use literal keys ('links', 'paramLinks', 'blockLinks').

Tests

  • test/gridNumericScan.test.ts (happy-dom, 41 tests). It rewrites sparse_binary.sldd as described above, loads it through getModelFromBytes and matrixPayload, and opens the grid against a 1 s bound. A control checks that long digit strings which are numbers stay right-aligned, and a table of 38 cells pins which cells read as numbers.
    • Old pattern: the two file-route timing tests fail at 7.5 to 7.7 s.
    • Mutants: a digit-requiring pattern fails 3 tests; a dot-less one fails 5.
  • test/removeTokenLinear.test.ts (3 tests). It removes a chip after 100,000 spaces, once bare and once inside a quoted value, against a 1 s bound, and checks the gap-closing over 16 whitespace characters.
    • Old code: both timing tests fail at 8.4 to 9.4 s.
  • test/mspLabelOwnKey.test.ts (36 tests). It parses monolithic stores through core's parseProject from the package barrel. Each of the twelve inherited names appears as an attached MSP label with its own id, and also sharing a Location with each half.
    • Old code: 9 of 36 fail, each with the half missing. They are exactly the names that sort after the label they share a Location with: core sorts the catalog by name, and the later label won.
    • Now: 36 of 36 pass, and msp.test.ts's 19 are unchanged.

No existing test was modified.

Verification

  • npm run verify on the final tree: exit 0. typecheck, build, build:web, 167 test files / 3,208 tests passed, check:leak's three checks OK.
    • On the first commit alone: one run failed with addEntryWiring.test.ts's beforeAll exceeding vitest's 10 s hook timeout, at a load average of about 18 on 12 cores. That file passed alone in 7.5 s, and the full rerun was green (166 files / 3,172 tests).
  • npm run test:integration: exit 0, 168 passing, 1 pending, on VS Code 1.141.0, which the fresh worktree downloaded.
  • Browser check: not run. The rewrite is equivalent over the populations above, so which cells align as numbers cannot change.

Notes

  • fflate drops a zip member named __proto__. unzipSync collects members into a plain object, so that member becomes the result's prototype instead of a key; I measured this on a hand-renamed zip. BinarySlddEditorProvider's pass-through bag therefore never sees it, and a save would drop that member. MATLAB does not write one. The loss is in the dependency, not in this repo's code, and is left for a separate decision.
  • Core has the same lookup bug. MatlabVariableNode's MCOS_ICON_MAP[this._opaqueClassName] is indexed by a class name read from a .mat file. As a prediction, not run: an object of class toString would get an icon that is a function, and JSON postMessage would drop it before it reaches the webview. That is for core.
  • Correction to the first commit message: "the nine with a quantifier over their input" should say ten. matrixPayload.ts:115's /^[0-9]+$/ is the tenth; it is listed and timed above. The commit was not amended.

ww-mw added 2 commits October 11, 2026 21:18
…hip in linear time

The Variable Editor grid tests every cell against NUMERIC to decide the block's
alignment, and it read a decimal as `\d+\.?\d*`. With no dot that is `\d+\d*`, and a
backtracking engine tries every split of a run of digits between the two before it gives
up, so a cell that starts with a long run of digits and is not a number cost the square
of its length: 1,000 digits and an `x` took 2.2 ms in the bare pattern, 10,000 took
215 ms and 100,000 took 21.4 s. A file reaches it. A binary dictionary's complex value is
held as its text, core's displayElements() hands each element over as that text, less
MATLAB's `.0`, and no complex element is a number, so the first cell is the one tested.
sparse_binary.sldd with dComplex's first element lengthened to 50,000 digits and
`.0+2.0i` held the grid's open for 7.7 s, and for 7.5 s with the digits and an `i`.

`\d+(?:\.\d*)?` matches the same strings and leaves every group where it was, as core's
rewrite of the same fragment did (mathworks/data-explorer-core#74). The bare pattern takes
0.4 ms on 100,000 digits and 4.4 ms on a million, and the grid test below runs in 40 to
50 ms, the dictionary's parse included. The old and new patterns agree on test() and on
every exec() group for all 2,593,742,460 strings of up to nine characters over
`019.eE+-ix` and the space, all 286,331,153 of up to seven over those and `InfNa`, and all
579,195 sequences of up to five tokens from those and `Inf`, `NaN`, `Infinity`, `12` and
`1.5`. Both patterns are read out of the two files rather than retyped. The harness tells
a wrong rewrite apart: of the 235,794,769 strings of up to eight characters, requiring a
digit after the dot differs on 27,981, dropping the dot on 89,892, and making the new
group capturing on the exec() groups of all 219,969 that match.

A sweep of the other 28 regexes in src/ found one more of the kind. removeToken in
rowFilter.ts trimmed the text before a removed chip with `.replace(/\s+$/, '')`, which is
anchored at the end only, so a run of whitespace that is not at the end cost the square
of its length: 160,000 spaces took 15.7 s in the bare pattern, and 100,000 of them
anywhere before a chip held its removal for 8.4 to 9.4 s. The text is typed: the filter
bar, or a header popup's value, which formatToken quotes and keeps whole. A chip's x,
Backspace into the chips and a popup's Clear all come here. trimEnd() removes the same
characters, `\s` and trim both being WhiteSpace and LineTerminator: the two agree on every
code point, alone and in four contexts, and on all 111,111,111 strings of up to eight
characters over ten symbols mixing ASCII and Unicode whitespace with other characters,
where trim(), a spaces-only pattern and an ASCII-only one each differ. The removal takes
about a millisecond now. Of the remaining 27, the nine with a quantifier over their input
stay linear when pumped to 160,000 characters, and the rest match a single character, a
literal, or at one anchored position.

gridNumericScan.test.ts opens the grid on the dictionary rewritten that way, through
getModelFromBytes and matrixPayload, and pins which cells read as numbers.
removeTokenLinear.test.ts removes a chip after a long run of whitespace, plain and
quoted. The four timing tests fail against the old code, at 7.5 to 9.4 s against a 1 s
bound, and pass with the new; a grid pattern that requires a digit after the dot, or has
no dot, fails the alignment table.
… neither half

detectMsp looked a project label's name up in LABEL_KIND, a plain object, and the name is
the project store's own text. On a plain object toString, toLocaleString, valueOf,
constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, __proto__ and the four
__define/__lookup accessors answer from Object.prototype, each of them truthy, so an MSP
label of any of those names was recorded against its id as one of the two halves. On a
well-formed store that went unseen: the member carrying it was pushed as a dictionary of
that "kind", and the interface-first ordering dropped it. A label sharing a Location with
InterfaceDictionary or PrivateDictionary overwrote that half's entry instead, and the half
went missing from the page; a project that lost its private half that way read as a shared
interface rather than a component. MATLAB does not write two labels at one Location, but a
monolithic store is one XML document, and core's parseProject reads both labels at the one
id.

The lookup is now of an own key, through Object.hasOwn, as mathworks/data-explorer-core#73
made classificationOf's.

mspLabelOwnKey.test.ts parses monolithic stores through core's parseProject, from the
package barrel: each of the twelve names as an MSP label with an id of its own and a member
carrying it, and sharing a Location with each half. 9 of the 36 failed before, each with
the half missing: every name that sorts after the label it shares a Location with, since
core sorts the catalog by name and the later label overwrote the earlier. All 36 pass now,
and msp.test.ts's 19 are unchanged.

The other two lookups named with this one are not reached by a file. dex-icon's ALIASES is
indexed by a row's iconId, and iconMap's MAP by a search result's kind: every icon id is a
constant of core's or of this extension's. Given `toString`, dex-icon would request
icons/function toString() { [native code] }.svg. A census of computed-key reads and writes
in src/, through the type checker, finds no other table or accumulator that a file's or a
user's text can index with an inherited name.
@ww-mw
ww-mw merged commit 4d10c23 into main Oct 12, 2026
4 checks passed
@ww-mw
ww-mw deleted the fix-grid-number-regex-and-lookups branch October 12, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant