diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a8e6996..75e9e36 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -40,6 +40,7 @@ body: - Contrast Checker - QR Code - Lorem Ipsum + - Regex Tester - The site itself (⌘K palette, sidebar, shortcuts, saved state) validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 554a576..9367a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: set -euo pipefail pnpm pack --pack-destination "$RUNNER_TEMP" >/dev/null LIST=$(tar -tzf "$RUNNER_TEMP"/fadeltd-devtools-core-*.tgz) - for sub in base64 color cron css csv diff env escape hash html-text id-gen json json-types jwt list lorem number qr random share slug sql text text-bytes text-stats timestamp unicode url yaml-toml; do + for sub in base64 color cron css csv diff env escape hash html-text id-gen json json-types jwt list lorem number qr random regex share slug sql text text-bytes text-stats timestamp unicode url yaml-toml; do # A here-string, not `echo | grep -q`: grep -q stops reading at the # first match, and under pipefail the writer's SIGPIPE fails the step. grep -qx "package/dist/$sub/index.d.ts" <<< "$LIST" || { echo "::error::Missing dist/$sub/index.d.ts"; exit 1; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb4bf5..006214c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -Nothing yet. +### Added +- Regex Tester: test a JavaScript regular expression against your text. Every match is listed with its line, column and capture groups by number and name (a group that did not take part is shown as such), and clicking one selects it in the text. Flags are toggles, a pasted `/pattern/flags` literal fills both, and errors say what is wrong without repeating the pattern. Replace mode previews the result and counts replacements, and warns when a reference silently does nothing (`$3` with two groups, `$0`, an unknown `$`, `\1`). Patterns run in a background worker, so one that backtracks without end, like `(a+)+$`, offers Cancel after two seconds instead of freezing the tab. ## [0.15.0] — 2026-10-30 diff --git a/README.md b/README.md index cab7e09..2413241 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ It also does what the free sites usually don't: reports **every** JSON error wit ### Text and lists - **[Diff Checker](https://devtools.fadeltd.dev/diff)** — side-by-side or unified, word-level highlights, ignore whitespace or case, compare as sorted lines or key-sorted JSON, unified patch, share by link +- **[Regex Tester](https://devtools.fadeltd.dev/regex)** — JavaScript regex with every match, line and column, named and numbered groups, replace preview; runaway patterns can be cancelled - **[List Tools](https://devtools.fadeltd.dev/list)** — sort, natural sort, dedupe, shuffle, filter and 11 more steps chained into a shareable pipeline - **[Word Counter & Text Statistics](https://devtools.fadeltd.dev/text-stats)** — characters four ways, words, sentences, reading time, word frequency - **[Unicode Inspector](https://devtools.fadeltd.dev/unicode)** — finds zero-width, bidi (Trojan Source) and other invisible characters, one-click clean diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 96ce0cf..790c08d 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -6,7 +6,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -Nothing yet. +### Added +- `/regex`: `runRegex({ pattern, flags, text, mode, replacement })` for a regex tester in one call, never throwing for a bad pattern. Built from `compileRegex` (errors as data, without the engine's echo of the pattern), `checkFlags`, `parseRegexLiteral`, `captureGroupNames`, `findMatches` (JavaScript's own semantics: all matches with `g`, the first without, empty matches stepping a code point under `u`/`v`, with line, column and group spans, capped at `MATCH_LIMIT`), `replaceWith` (exactly `String.prototype.replace`, plus a count) with `expandTemplate`, and `replacementIssues`. ## [0.7.0] — 2026-10-30 diff --git a/packages/core/README.md b/packages/core/README.md index e2394f6..f67bb22 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -148,6 +148,7 @@ await formatSql('select a from t where b = 1', { dialect: 'postgresql', keywordC | `/escape` | JSON, JavaScript, SQL (standard/MySQL), POSIX shell, regex and HTML-entity escaping and strict unescaping, with positioned errors | | `/color` | CSS color parsing with positioned errors; sRGB ↔ HSL ↔ OKLCH; gamut detection and clipping; WCAG contrast and nearest passing foreground | | `/qr` | QR payloads for text, URLs and Wi-Fi, encoded to a module matrix, plus a single-path SVG | +| `/regex` | Flag checks, readable compile errors, `/pattern/flags` literals, every match with line, column and named group spans (JavaScript semantics, capped), and `replace` with a count and warnings for references that silently do nothing | | `/lorem` | Lorem ipsum by exact word, sentence or paragraph count, as text, HTML or Markdown, seedable | | `/text` | Line/column offsets, character counts, UTF-8 size without encoding and long-line detection, shared by the tools | | `/share` | Versioned, URL-safe compact encoding of any JSON value (deflate-raw + base64url) with a decode cap, for links that keep their content in the URL fragment | diff --git a/packages/core/package.json b/packages/core/package.json index 160e044..81e27c5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,10 @@ "types": "./dist/random/index.d.ts", "default": "./dist/random/index.js" }, + "./regex": { + "types": "./dist/regex/index.d.ts", + "default": "./dist/regex/index.js" + }, "./share": { "types": "./dist/share/index.d.ts", "default": "./dist/share/index.js" diff --git a/packages/core/src/regex/compile.test.ts b/packages/core/src/regex/compile.test.ts new file mode 100644 index 0000000..ba5fbd7 --- /dev/null +++ b/packages/core/src/regex/compile.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { captureGroupNames, checkFlags, compileRegex, parseRegexLiteral } from './compile.ts' + +describe('checkFlags', () => { + it('accepts every valid combination it is given', () => { + expect(checkFlags('')).toBeNull() + expect(checkFlags('dgimsuy')).toBeNull() + expect(checkFlags('gv')).toBeNull() + }) + + it('names the problem', () => { + expect(checkFlags('gx')).toBe('Unknown flag "x". JavaScript has d, g, i, m, s, u, v, y.') + expect(checkFlags('gig')).toBe('The flag "g" appears twice.') + expect(checkFlags('uv')).toMatch(/cannot be combined/) + }) +}) + +describe('compileRegex', () => { + it('compiles', () => { + const r = compileRegex('a(b)', 'gi') + expect(r.ok && r.regex.flags).toBe('gi') + }) + + it('reports a pattern error without repeating the pattern', () => { + const r = compileRegex('(abc', 'g') + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.kind).toBe('pattern') + expect(r.message).not.toMatch(/Invalid regular expression/) + expect(r.message).not.toMatch(/\(abc/) + expect(r.message.length).toBeGreaterThan(0) + }) + + it('reports flag errors before compiling', () => { + expect(compileRegex('a', 'q')).toMatchObject({ ok: false, kind: 'flags' }) + }) + + it('accepts u-mode syntax only with u', () => { + expect(compileRegex('\\p{L}', 'u').ok).toBe(true) + expect(compileRegex('\\p{L}', 'v').ok).toBe(true) + }) +}) + +describe('parseRegexLiteral', () => { + it('splits a literal', () => { + expect(parseRegexLiteral('/a\\/b/gi')).toEqual({ pattern: 'a\\/b', flags: 'gi' }) + expect(parseRegexLiteral(' /x/ ')).toEqual({ pattern: 'x', flags: '' }) + }) + + it('leaves anything else alone', () => { + expect(parseRegexLiteral('abc')).toBeNull() + expect(parseRegexLiteral('/')).toBeNull() + expect(parseRegexLiteral('//')).toBeNull() + expect(parseRegexLiteral('/path/to/file.txt')).toBeNull() + expect(parseRegexLiteral('/a/G')).toBeNull() + }) +}) + +describe('captureGroupNames', () => { + const cases: Array<[string, string, Array]> = [ + ['abc', '', []], + ['(a)(b)', '', [null, null]], + ['(?\\d{4})-(\\d{2})', '', ['year', null]], + ['(?:a)(?=b)(?!c)(?<=d)(?x)|(?y)', '', ['a', 'a']], + ['((a)(?b))', '', [null, null, 'n']], + ['(?i:a)(b)', '', [null]], + ] + + it.each(cases)('%s /%s', (pattern, flags, expected) => { + expect(captureGroupNames(pattern, flags)).toEqual(expected) + }) + + it('agrees with the engine on the group count', () => { + for (const [pattern, flags] of cases) { + let re: RegExp + try { + re = new RegExp(`${pattern}|`, flags) + } catch { + continue // duplicate names need a newer engine + } + expect(captureGroupNames(pattern, flags)).toHaveLength(re.exec('')!.length - 1) + } + }) +}) diff --git a/packages/core/src/regex/compile.ts b/packages/core/src/regex/compile.ts new file mode 100644 index 0000000..52dabf5 --- /dev/null +++ b/packages/core/src/regex/compile.ts @@ -0,0 +1,121 @@ +/** Every flag a JavaScript RegExp accepts, in the order `RegExp.prototype.flags` prints them. */ +export const REGEX_FLAGS = ['d', 'g', 'i', 'm', 's', 'u', 'v', 'y'] as const + +export type RegexFlag = (typeof REGEX_FLAGS)[number] + +export const FLAG_INFO: Record = { + d: { name: 'indices', description: 'Record where each capture group starts and ends' }, + g: { name: 'global', description: 'Find every match, not just the first' }, + i: { name: 'ignore case', description: 'Match letters regardless of case' }, + m: { name: 'multiline', description: '^ and $ match at line breaks, not only at the ends of the text' }, + s: { name: 'dotAll', description: '. also matches line breaks' }, + u: { name: 'unicode', description: 'Treat the pattern as code points, and enable \\p{…} and \\u{…}' }, + v: { name: 'unicodeSets', description: 'Like u, plus set operations and string properties in classes' }, + y: { name: 'sticky', description: 'Match only exactly where the last match ended' }, +} + +export type CompileResult = + | { ok: true; regex: RegExp } + | { ok: false; kind: 'flags' | 'pattern'; message: string } + +/** + * Problems with a flags string, worded for a person, or null when it is fine. + * Checked before the engine sees it: its own messages ("Invalid flags supplied + * to RegExp constructor") do not say which flag is wrong. + */ +export function checkFlags(flags: string): string | null { + const seen = new Set() + for (const f of flags) { + if (!(REGEX_FLAGS as readonly string[]).includes(f)) { + return `Unknown flag "${f}". JavaScript has ${REGEX_FLAGS.join(', ')}.` + } + if (seen.has(f)) return `The flag "${f}" appears twice.` + seen.add(f) + } + if (seen.has('u') && seen.has('v')) return 'The u and v flags cannot be combined: v is the newer form of u.' + for (const f of seen) { + try { + void new RegExp('', f) + } catch { + return `This JavaScript engine does not support the "${f}" flag.` + } + } + return null +} + +/** + * The engine prefixes its message with the whole pattern + * ("Invalid regular expression: /(/: Unterminated group"), which repeats what + * is already on screen and can run to thousands of characters. + */ +const ENGINE_PREFIX = /^Invalid regular expression: \/[\s\S]*\/[a-z]*: / + +/** + * Compiles a pattern and flags as `new RegExp(pattern, flags)` would, with + * errors as data. Compiling never runs the pattern, so it cannot hang. + */ +export function compileRegex(pattern: string, flags: string): CompileResult { + const flagProblem = checkFlags(flags) + if (flagProblem !== null) return { ok: false, kind: 'flags', message: flagProblem } + try { + return { ok: true, regex: new RegExp(pattern, flags) } + } catch (e) { + const raw = e instanceof Error ? e.message : String(e) + const message = raw.replace(ENGINE_PREFIX, '') + return { ok: false, kind: 'pattern', message: message.charAt(0).toUpperCase() + message.slice(1) } + } +} + +/** + * Splits a pasted regex literal, `/pattern/flags`, into its parts. Null when + * the text is not one, so it can be used as an ordinary pattern instead. + */ +export function parseRegexLiteral(text: string): { pattern: string; flags: string } | null { + const trimmed = text.trim() + if (!trimmed.startsWith('/')) return null + const close = trimmed.lastIndexOf('/') + if (close <= 0) return null + const flags = trimmed.slice(close + 1) + if (!/^[a-z]*$/.test(flags)) return null + const pattern = trimmed.slice(1, close) + if (pattern === '') return null + return { pattern, flags } +} + +/** + * The capture groups of a pattern in the order the engine numbers them: the + * group's name, or null for an unnamed one. Expects a pattern that compiles. + * + * Needed because a match only reports names through `groups`, keyed by name, + * which cannot tell which number a name belongs to. + */ +export function captureGroupNames(pattern: string, flags = ''): Array { + const names: Array = [] + // With v, classes nest ([a[bc]]); without it, a [ inside a class is literal. + const nested = flags.includes('v') + let classDepth = 0 + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i] + if (c === '\\') { + i++ + continue + } + if (classDepth > 0) { + if (c === ']') classDepth-- + else if (c === '[' && nested) classDepth++ + continue + } + if (c === '[') { + classDepth = 1 + continue + } + if (c !== '(') continue + if (pattern[i + 1] !== '?') { + names.push(null) + } else if (pattern[i + 2] === '<' && pattern[i + 3] !== '=' && pattern[i + 3] !== '!') { + const end = pattern.indexOf('>', i + 3) + names.push(pattern.slice(i + 3, end)) + } + } + return names +} diff --git a/packages/core/src/regex/index.ts b/packages/core/src/regex/index.ts new file mode 100644 index 0000000..38cc96e --- /dev/null +++ b/packages/core/src/regex/index.ts @@ -0,0 +1,4 @@ +export * from './compile.ts' +export * from './match.ts' +export * from './replace.ts' +export * from './run.ts' diff --git a/packages/core/src/regex/match.test.ts b/packages/core/src/regex/match.test.ts new file mode 100644 index 0000000..81e6331 --- /dev/null +++ b/packages/core/src/regex/match.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { findMatches } from './match.ts' + +describe('findMatches', () => { + it('finds every match with g, with position and groups', () => { + const r = findMatches('2024-10-30\nand 1999-01-02', /(?\d{4})-(\d{2})/g) + expect(r.truncated).toBe(false) + expect(r.groupNames).toEqual(['year', null]) + expect(r.matches.map((m) => [m.text, m.line, m.column])).toEqual([ + ['2024-10', 1, 1], + ['1999-01', 2, 5], + ]) + expect(r.matches[1]!.groups).toEqual([ + { number: 1, name: 'year', text: '1999', start: 15, end: 19 }, + { number: 2, name: null, text: '01', start: 20, end: 22 }, + ]) + }) + + it('finds only the first match without g', () => { + expect(findMatches('a a a', /a/).matches).toHaveLength(1) + }) + + it('reports a group that did not take part as null', () => { + const [m] = findMatches('b', /(a)?b/g).matches + expect(m!.groups[0]).toEqual({ number: 1, name: null, text: null, start: null, end: null }) + }) + + it('steps over empty matches, by code point in unicode mode', () => { + expect(findMatches('baa', /a*/g).matches.map((m) => [m.start, m.text])).toEqual([ + [0, ''], + [1, 'aa'], + [3, ''], + ]) + expect(findMatches('😀', /(?:)/gu).matches.map((m) => m.start)).toEqual([0, 2]) + expect(findMatches('😀', /(?:)/g).matches.map((m) => m.start)).toEqual([0, 1, 2]) + }) + + it('stops at the first gap with g and y', () => { + expect(findMatches('aab a', /a/gy).matches).toHaveLength(2) + }) + + it('stops at the limit and says so', () => { + const r = findMatches('aaaa', /a/g, { limit: 3 }) + expect(r.matches).toHaveLength(3) + expect(r.truncated).toBe(true) + expect(findMatches('aaa', /a/g, { limit: 3 }).truncated).toBe(false) + }) + + it('does not touch the lastIndex of the regex it is given', () => { + const re = /a/g + re.lastIndex = 2 + findMatches('aaa', re) + expect(re.lastIndex).toBe(2) + }) + + it('agrees with matchAll', () => { + const patterns = ['a', 'a*', 'b|', '(a)(b)?', '(?[ab])\\1', '^', '$', '\\b', '.', '(?:)', '[^a]+'] + fc.assert( + fc.property( + fc.string({ unit: fc.constantFrom('a', 'b', ' ', '\n', '😀') }), + fc.constantFrom(...patterns), + fc.constantFrom('g', 'gm', 'gu', 'gi', 'gs', 'gmu'), + (text, pattern, flags) => { + const re = new RegExp(pattern, flags) + const expected = [...text.matchAll(re)].map((m) => [m.index, m[0], m.slice(1).map((g) => g ?? null)]) + const actual = findMatches(text, re).matches.map((m) => [m.start, m.text, m.groups.map((g) => g.text)]) + expect(actual).toEqual(expected) + }, + ), + ) + }) +}) diff --git a/packages/core/src/regex/match.ts b/packages/core/src/regex/match.ts new file mode 100644 index 0000000..a3f789c --- /dev/null +++ b/packages/core/src/regex/match.ts @@ -0,0 +1,101 @@ +import { buildLineIndex, offsetToLineCol } from '../text/offsets.ts' +import { captureGroupNames } from './compile.ts' + +export interface RegexGroup { + /** 1-based, as in `$1`. */ + number: number + name: string | null + /** Null when the group did not take part in the match. */ + text: string | null + start: number | null + end: number | null +} + +export interface RegexMatch { + /** UTF-16 offsets, as a textarea's selection uses them. */ + start: number + end: number + text: string + /** 1-based; the column counts UTF-16 code units. */ + line: number + column: number + groups: RegexGroup[] +} + +export interface MatchResult { + matches: RegexMatch[] + /** More matches exist past `limit`. */ + truncated: boolean + /** Each capture group's name, or null, by group number - 1. */ + groupNames: Array +} + +/** Enough for any table someone reads; past it, the count says "10,000+". */ +export const MATCH_LIMIT = 10_000 + +/** + * What a JavaScript program would get from this regex: every match with `g` + * (as `matchAll` does), otherwise only the first (as `exec` does). Each + * match carries its line and column and every group's span. + * + * An empty match moves on by one character (a whole code point with `u` or + * `v`), as the engine itself does, so `a*` over "baa" finds "", "aa", "". + * With `g` and `y` together, matching stops at the first gap. + * + * This runs the user's pattern: call it in a worker, which can be terminated + * if the pattern backtracks without end. + */ +export function findMatches(text: string, regex: RegExp, { limit = MATCH_LIMIT } = {}): MatchResult { + const groupNames = captureGroupNames(regex.source, regex.flags) + // d records group spans and changes nothing about what matches. + const re = regex.hasIndices ? new RegExp(regex) : new RegExp(regex.source, `${regex.flags}d`) + re.lastIndex = 0 + const unicode = re.unicode || re.flags.includes('v') + + const raw: RegExpExecArray[] = [] + let truncated = false + for (;;) { + const m = re.exec(text) + if (m === null) break + if (raw.length === limit) { + truncated = true + break + } + raw.push(m) + if (!re.global) break + if (m[0] === '') re.lastIndex = advance(text, re.lastIndex, unicode) + } + + const lines = raw.length > 0 ? buildLineIndex(text) : null + const matches = raw.map((m): RegexMatch => { + const { line, column } = offsetToLineCol(lines!, m.index) + const indices = m.indices! + return { + start: m.index, + end: m.index + m[0].length, + text: m[0], + line, + column, + groups: groupNames.map((name, i) => { + const span = indices[i + 1] + return { + number: i + 1, + name, + text: m[i + 1] ?? null, + start: span?.[0] ?? null, + end: span?.[1] ?? null, + } + }), + } + }) + return { matches, truncated, groupNames } +} + +/** The engine's AdvanceStringIndex: past a whole surrogate pair in unicode mode. */ +function advance(text: string, index: number, unicode: boolean): number { + if (!unicode || index + 1 >= text.length) return index + 1 + const c = text.charCodeAt(index) + if (c < 0xd800 || c > 0xdbff) return index + 1 + const next = text.charCodeAt(index + 1) + return next >= 0xdc00 && next <= 0xdfff ? index + 2 : index + 1 +} diff --git a/packages/core/src/regex/replace.test.ts b/packages/core/src/regex/replace.test.ts new file mode 100644 index 0000000..e174e34 --- /dev/null +++ b/packages/core/src/regex/replace.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { replacementIssues, replaceWith } from './replace.ts' + +describe('replaceWith', () => { + it('replaces and counts', () => { + expect(replaceWith('a-b-c', /-/g, '+')).toEqual({ output: 'a+b+c', count: 2 }) + expect(replaceWith('a-b-c', /-/, '+')).toEqual({ output: 'a+b-c', count: 1 }) + expect(replaceWith('abc', /x/g, '+')).toEqual({ output: 'abc', count: 0 }) + }) + + it('expands every reference form', () => { + expect(replaceWith('2024-10', /(?\d+)-(\d+)/, '$2/$ [$&] $$')).toEqual({ output: '10/2024 [2024-10] $', count: 1 }) + expect(replaceWith('xay', /a/, "[$`|$']")).toEqual({ output: 'x[x|y]y', count: 1 }) + }) + + it('matches String.prototype.replace for any template', () => { + const regexes = [/(a)(b)?/g, /(?a)|(?b)/g, /a/g, /(a)/, /x/g, /(?:)/g, /(a)(a)(a)(a)(a)(a)(a)(a)(a)(a)(a)/g] + fc.assert( + fc.property( + fc.string({ unit: fc.constantFrom('a', 'b', 'c') }), + fc.string({ unit: fc.constantFrom('$', '&', '`', "'", '<', '>', 'n', 'm', 'q', '0', '1', '2', '9', 'x') }), + fc.constantFrom(...regexes), + (text, template, regex) => { + expect(replaceWith(text, regex, template).output).toBe(text.replace(new RegExp(regex), template)) + }, + ), + ) + }) +}) + +const issues = (template: string, names: Array) => + replacementIssues(template, names).map((i) => i.message) + +describe('replacementIssues', () => { + it('says nothing about valid references', () => { + expect(issues('$1 $ $& $$1 $` $\'', [null, 'y'])).toEqual([]) + }) + + it('flags references that are inserted as written or as nothing', () => { + expect(issues('$3', [null])).toEqual(['$3 is inserted as written: the pattern has 1 capture group.']) + expect(issues('$1', [])).toEqual(['$1 is inserted as written: the pattern has no capture groups.']) + expect(issues('$0', [null])).toEqual(['$0 is inserted as written: there is no group 0. Use $& for the whole match.']) + expect(issues('$', [null])).toEqual(['$ is inserted as written: the pattern has no named groups.']) + expect(issues('$', ['y'])).toEqual(['$ inserts nothing: no group is named "z".']) + expect(issues('$, so it is inserted as written.']) + expect(issues('\\1', [null])).toEqual(['\\1 is inserted as written: JavaScript replacements use $1.']) + }) + + it('reads $10 the way the engine does', () => { + // One group: $10 is $1 followed by "0", so it is fine. + expect(issues('$10', [null])).toEqual([]) + }) + + it('reports each reference once', () => { + expect(issues('$5 $5', [])).toHaveLength(1) + }) +}) diff --git a/packages/core/src/regex/replace.ts b/packages/core/src/regex/replace.ts new file mode 100644 index 0000000..02cfaa6 --- /dev/null +++ b/packages/core/src/regex/replace.ts @@ -0,0 +1,161 @@ +import { captureGroupNames } from './compile.ts' + +export interface ReplaceResult { + output: string + /** How many matches were replaced. */ + count: number +} + +/** + * `text.replace(regex, template)`, exactly, plus a count of replacements. + * + * The template is expanded by `expandTemplate`, a copy of the language's own + * rules, because a replacer function is the only way to count and it loses + * the `$1` / `$` handling of a template string. + * + * This runs the user's pattern: call it in a worker. + */ +export function replaceWith(text: string, regex: RegExp, template: string): ReplaceResult { + const groups = captureGroupNames(regex.source, regex.flags).length + let count = 0 + const re = new RegExp(regex) + const output = text.replace(re, (...args: unknown[]) => { + count++ + const captures = args.slice(1, 1 + groups) as Array + const position = args[1 + groups] as number + const named = args[3 + groups] as Record | undefined + return expandTemplate(template, args[0] as string, text, position, captures, named) + }) + return { output, count } +} + +/** + * The language's GetSubstitution: `$$`, `$&`, `` $` ``, `$'`, `$1`–`$99` and + * `$`, with every edge it defines (`$0` and a number past the last group + * stay as written; `$10` falls back to `$1` then "0" when there are fewer than + * ten groups; `$` is literal when the pattern has no named groups). + */ +export function expandTemplate( + template: string, + matched: string, + str: string, + position: number, + captures: ReadonlyArray, + named: Readonly> | undefined, +): string { + let out = '' + let i = 0 + while (i < template.length) { + const dollar = template.indexOf('$', i) + if (dollar === -1 || dollar === template.length - 1) { + out += template.slice(i) + break + } + out += template.slice(i, dollar) + const next = template[dollar + 1]! + if (next === '$') { + out += '$' + i = dollar + 2 + } else if (next === '&') { + out += matched + i = dollar + 2 + } else if (next === '`') { + out += str.slice(0, position) + i = dollar + 2 + } else if (next === "'") { + out += str.slice(Math.min(position + matched.length, str.length)) + i = dollar + 2 + } else if (isDigit(next)) { + const ref = numberedRef(template, dollar, captures.length) + out += ref.group === null ? template.slice(dollar, dollar + ref.length) : (captures[ref.group - 1] ?? '') + i = dollar + ref.length + } else if (next === '<') { + const close = named === undefined ? -1 : template.indexOf('>', dollar + 2) + if (close === -1) { + out += '$<' + i = dollar + 2 + } else { + out += named![template.slice(dollar + 2, close)] ?? '' + i = close + 1 + } + } else { + out += '$' + i = dollar + 1 + } + } + return out +} + +/** `$n` or `$nn` at `dollar`: its length and the group it names, or null when it stays as written. */ +function numberedRef(template: string, dollar: number, groups: number): { length: number; group: number | null } { + const twoDigits = isDigit(template[dollar + 2]) + let length = twoDigits ? 3 : 2 + let index = Number(template.slice(dollar + 1, dollar + length)) + if (index > groups && length === 3) { + length = 2 + index = Number(template[dollar + 1]) + } + return { length, group: index >= 1 && index <= groups ? index : null } +} + +function isDigit(c: string | undefined): boolean { + return c !== undefined && c >= '0' && c <= '9' +} + +export interface ReplacementIssue { + /** The reference as written in the template. */ + ref: string + message: string +} + +/** + * References in a replacement template that do not do what they look like + * they do. Each is legal, so JavaScript never complains; it silently inserts + * the reference as written, or nothing. + */ +export function replacementIssues(template: string, groupNames: ReadonlyArray): ReplacementIssue[] { + const groups = groupNames.length + const names = new Set(groupNames.filter((n): n is string => n !== null)) + const issues = new Map() + + for (let i = 0; i < template.length; i++) { + const c = template[i] + if (c === '\\' && isDigit(template[i + 1]) && template[i + 1] !== '0') { + const ref = template.slice(i, i + 2) + issues.set(ref, `${ref} is inserted as written: JavaScript replacements use $${template[i + 1]}.`) + continue + } + if (c !== '$' || i === template.length - 1) continue + const next = template[i + 1]! + if (next === '$') { + i++ + } else if (isDigit(next)) { + const ref = numberedRef(template, i, groups) + const written = template.slice(i, i + ref.length) + if (ref.group === null) { + issues.set( + written, + Number(written.slice(1)) === 0 + ? `${written} is inserted as written: there is no group 0. Use $& for the whole match.` + : `${written} is inserted as written: the pattern has ${groups === 0 ? 'no capture groups' : groups === 1 ? '1 capture group' : `${groups} capture groups`}.`, + ) + } + i += ref.length - 1 + } else if (next === '<') { + const close = template.indexOf('>', i + 2) + if (names.size === 0) { + const ref = close === -1 ? '$<' : template.slice(i, close + 1) + issues.set(ref, `${ref} is inserted as written: the pattern has no named groups.`) + } else if (close === -1) { + issues.set('$<', '$< has no closing >, so it is inserted as written.') + } else { + const name = template.slice(i + 2, close) + if (!names.has(name)) { + issues.set(template.slice(i, close + 1), `$<${name}> inserts nothing: no group is named "${name}".`) + } + i = close + } + } + } + return [...issues].map(([ref, message]) => ({ ref, message })) +} diff --git a/packages/core/src/regex/run.test.ts b/packages/core/src/regex/run.test.ts new file mode 100644 index 0000000..a30a567 --- /dev/null +++ b/packages/core/src/regex/run.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { runRegex } from './run.ts' + +const base = { pattern: '(\\w)+', flags: 'g', text: 'ab cd', mode: 'match' as const, replacement: '' } + +describe('runRegex', () => { + it('matches in match mode', () => { + const r = runRegex(base) + expect(r.ok && r.match?.matches.map((m) => m.text)).toEqual(['ab', 'cd']) + expect(r.ok && r.replace).toBeNull() + }) + + it('replaces in replace mode, with issues', () => { + const r = runRegex({ ...base, mode: 'replace', replacement: '<$1$2>' }) + expect(r.ok && r.match).toBeNull() + expect(r.ok && r.replace).toEqual({ + output: ' ', + count: 2, + issues: [{ ref: '$2', message: '$2 is inserted as written: the pattern has 1 capture group.' }], + }) + }) + + it('returns a bad pattern or flags as data', () => { + expect(runRegex({ ...base, pattern: '[' })).toMatchObject({ ok: false, kind: 'pattern' }) + expect(runRegex({ ...base, flags: 'gg' })).toMatchObject({ ok: false, kind: 'flags' }) + }) +}) diff --git a/packages/core/src/regex/run.ts b/packages/core/src/regex/run.ts new file mode 100644 index 0000000..226e9cf --- /dev/null +++ b/packages/core/src/regex/run.ts @@ -0,0 +1,45 @@ +import { captureGroupNames, compileRegex } from './compile.ts' +import { findMatches, MATCH_LIMIT, type MatchResult } from './match.ts' +import { replacementIssues, replaceWith, type ReplacementIssue, type ReplaceResult } from './replace.ts' + +export type RegexMode = 'match' | 'replace' + +export interface RegexRunInput { + pattern: string + flags: string + text: string + mode: RegexMode + replacement: string + limit?: number +} + +export type RegexRun = + | { ok: false; kind: 'flags' | 'pattern'; message: string } + | { + ok: true + groupNames: Array + /** Set in match mode. */ + match: MatchResult | null + /** Set in replace mode. */ + replace: (ReplaceResult & { issues: ReplacementIssue[] }) | null + } + +/** + * Everything the regex tester shows for one input, in one call, so a worker + * can run it whole. Never throws for a bad pattern or flags; it does run the + * pattern, so it belongs in a worker that can be terminated. + */ +export function runRegex({ pattern, flags, text, mode, replacement, limit = MATCH_LIMIT }: RegexRunInput): RegexRun { + const compiled = compileRegex(pattern, flags) + if (!compiled.ok) return compiled + const groupNames = captureGroupNames(pattern, flags) + if (mode === 'replace') { + return { + ok: true, + groupNames, + match: null, + replace: { ...replaceWith(text, compiled.regex, replacement), issues: replacementIssues(replacement, groupNames) }, + } + } + return { ok: true, groupNames, match: findMatches(text, compiled.regex, { limit }), replace: null } +} diff --git a/src/lib/registry/about.ts b/src/lib/registry/about.ts index aee05a4..666cf6b 100644 --- a/src/lib/registry/about.ts +++ b/src/lib/registry/about.ts @@ -35,4 +35,5 @@ export const ABOUT: Record = { 'qr': 'Wi-Fi codes escape reserved characters in both the SSID and the password, add H:true only for a hidden network, and warn about a WPA password outside 8 to 63 characters or an SSID over 32 UTF-8 bytes. A bare host gets https:// added, while host:port is not mistaken for a scheme. The quiet zone defaults to 4 modules, with warnings for a thinner one, for version 20 and above, for low contrast, and for an inverted code some scanners cannot read. The SVG is a single path, and the PNG is snapped to whole pixels per module.', 'sql': 'Formatting uses sql-formatter across 12 dialects, from standard SQL and PostgreSQL to BigQuery, T-SQL, Spark and Trino, and a parse or lexer error is reported at its line and column. Minifying is separate and dialect-aware: string literals, quoted identifiers and dollar-quoted bodies are left untouched, backslash escapes are read only where the dialect has them, # starts a comment only in MySQL-like dialects, and BigQuery triple-quoted strings are understood. Comments are kept by default and can be stripped, except optimizer hints and MySQL versioned comments.', 'css': 'Pretty-printing uses Prettier with its PostCSS parser, so CSS, SCSS and Less are all handled, and a syntax error or an unclosed block is reported at its line and column. The CSS minifier keeps /*! licence comments, the spaces calc() needs around + and -, the space that makes a descendant pseudo-class, and the space that ends a hex escape, and it leaves strings and unquoted url(data:...) bodies verbatim. It is idempotent, even on malformed input, and it drops the last semicolon in each block and any empty declarations.', + 'regex': 'Patterns run on your browser’s own JavaScript engine in a background worker, so one that backtracks without end, like (a+)+$, can be cancelled instead of freezing the tab. Matching follows JavaScript exactly: every match with g, only the first without it, and an empty match steps forward a whole code point under u or v. Each match lists its line, column and every capture group by number and name, and a group that did not take part is shown as such, not as an empty string. Replace mode expands $1, $ and $& as String.replace does, and warns when $3 names a missing group or $0 was meant as the whole match.', } diff --git a/src/lib/registry/index.ts b/src/lib/registry/index.ts index 7fd9e4f..a6982c3 100644 --- a/src/lib/registry/index.ts +++ b/src/lib/registry/index.ts @@ -20,6 +20,7 @@ import { Pilcrow, QrCode, Quote, + Regex, ScanText, Sigma, Signature, @@ -482,6 +483,18 @@ const RAW_TOOLS = [ stateVersion: 1, status: 'beta', }, + { + slug: 'regex', + title: 'Regex Tester', + blurb: 'Test a JavaScript regular expression: every match with its groups, and a replace preview.', + keywords: ['regex', 'regexp', 'regular expression', 'regex tester', 'named groups', 'capture groups', 'regex replace', 'pattern', 'match', 'javascript regex'], + category: 'text', + icon: Regex, + load: () => import('@/tools/regex/RegexTool'), + store: { kind: 'idb' }, + stateVersion: 1, + status: 'beta', + }, ] as const satisfies readonly ToolDef[] /** The literal union that keeps persistence keys, links and pins honest. */ diff --git a/src/tools/json/narrow.ts b/src/lib/util/narrow.ts similarity index 100% rename from src/tools/json/narrow.ts rename to src/lib/util/narrow.ts diff --git a/src/tools/json/JsonTool.tsx b/src/tools/json/JsonTool.tsx index ce287aa..0724aa5 100644 --- a/src/tools/json/JsonTool.tsx +++ b/src/tools/json/JsonTool.tsx @@ -62,7 +62,7 @@ import { } from '@fadeltd/devtools-core/json' import { hasLongLine } from '@fadeltd/devtools-core/text' import type { FoldApi } from './FoldableOutput' -import { useNarrow } from './narrow' +import { useNarrow } from '@/lib/util/narrow' import { TreeView } from './TreeView' // CodeMirror stays out of this chunk: it loads the first time formatted output diff --git a/src/tools/json/TreeView.tsx b/src/tools/json/TreeView.tsx index 051b2e5..1aad8b6 100644 --- a/src/tools/json/TreeView.tsx +++ b/src/tools/json/TreeView.tsx @@ -18,7 +18,7 @@ import { toJsonPointer, toJqPath, } from '@fadeltd/devtools-core/json' -import { useNarrow } from './narrow' +import { useNarrow } from '@/lib/util/narrow' // Matches the palette rows: a 44px touch target below md, the 22px code-table // grid from DESIGN.md above it. diff --git a/src/tools/regex/RegexTool.tsx b/src/tools/regex/RegexTool.tsx new file mode 100644 index 0000000..1eee4be --- /dev/null +++ b/src/tools/regex/RegexTool.tsx @@ -0,0 +1,476 @@ +import type { Remote } from 'comlink' +import { useDeferredValue, useEffect, useMemo, useRef, type ClipboardEvent, type ReactNode } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { AlertTriangle } from 'lucide-react' +import { ToolFrame } from '@/components/layout/ToolFrame' +import { TwoPane } from '@/components/layout/TwoPane' +import { Badge } from '@/components/ui/Badge' +import { Button, Segmented } from '@/components/ui/Button' +import { CodeArea } from '@/components/ui/CodeArea' +import { CopyButton } from '@/components/ui/CopyButton' +import { useToolState } from '@/lib/persist/useToolState' +import { useShortcuts } from '@/lib/keys/useShortcuts' +import { useToolUsageTracker } from '@/lib/prefs' +import { copyText } from '@/lib/util/clipboard' +import { formatCount } from '@/lib/util/bytes' +import { cn } from '@/lib/util/cn' +import { useWorkerJob } from '@/lib/worker/useWorkerJob' +import { useNarrow } from '@/lib/util/narrow' +import { + FLAG_INFO, + MATCH_LIMIT, + parseRegexLiteral, + REGEX_FLAGS, + type RegexFlag, + type RegexMatch, + type RegexMode, + type RegexRun, + type RegexRunInput, +} from '@fadeltd/devtools-core/regex' +import { hasLongLine } from '@fadeltd/devtools-core/text' +import type { RegexApi } from './regex.worker' + +interface State { + pattern: string + flags: string + text: string + mode: RegexMode + replacement: string +} + +const INITIAL: State = { pattern: '', flags: 'g', text: '', mode: 'match', replacement: '' } + +const MODES = [ + { value: 'match', label: 'Match', title: 'List every match and its groups' }, + { value: 'replace', label: 'Replace', title: 'Preview the text with each match replaced' }, +] as const + +/** A finished run, tagged with the input it was run on. */ +interface Outcome { + input: RegexRunInput + run: RegexRun | null +} + +// Module-level so useWorkerJob sees stable references. +function spawnRegex() { + return new Worker(new URL('./regex.worker.ts', import.meta.url), { type: 'module' }) +} + +async function runInWorker(api: Remote, input: RegexRunInput): Promise { + // An empty pattern matches at every position; there is nothing to show. + if (input.pattern === '') return { input, run: null } + return { input, run: await api.run(input) } +} + +/** Flags in the order the engine prints them; u and v exclude each other. */ +function toggleFlag(flags: string, flag: RegexFlag): string { + const set = new Set(flags) + if (set.has(flag)) { + set.delete(flag) + } else { + set.add(flag) + if (flag === 'u') set.delete('v') + if (flag === 'v') set.delete('u') + } + const known = REGEX_FLAGS.filter((f) => set.has(f)).join('') + const unknown = [...set].filter((f) => !(REGEX_FLAGS as readonly string[]).includes(f)).join('') + return known + unknown +} + +/** One line for a table cell: breaks and tabs shown, long values cut. */ +function cellText(text: string): string { + const cut = text.length > 200 ? `${text.slice(0, 200)}…` : text + return cut.replace(/\r\n|\r|\n/g, '⏎').replace(/\t/g, '→') +} + +const ROW_NARROW = 44 +const ROW_WIDE = 22 + +// One grid for header and rows, so the columns line up. +const GRID = 'grid grid-cols-[2.5rem_4rem_minmax(6rem,1fr)_minmax(8rem,1.5fr)] items-center gap-x-3' + +export default function RegexTool() { + useToolUsageTracker('regex') + const { state, setState, reset, hydrating } = useToolState('regex', INITIAL) + + const deferred = useDeferredValue(state) + const input = useMemo( + () => ({ + pattern: deferred.pattern, + flags: deferred.flags, + text: deferred.text, + mode: deferred.mode, + // The replacement only matters in replace mode; ignoring it otherwise + // keeps an edit to it from re-running the match. + replacement: deferred.mode === 'replace' ? deferred.replacement : '', + }), + [deferred.pattern, deferred.flags, deferred.text, deferred.mode, deferred.replacement], + ) + const job = useWorkerJob(spawnRegex, runInWorker, input) + + // The last finished run of the current mode; while a new one is running, + // it stays on screen rather than blanking on every keystroke. + const outcome = job.result?.input.mode === state.mode ? job.result : undefined + const run = state.pattern === '' ? null : (outcome?.run ?? null) + const failed = run !== null && !run.ok ? run : null + const ok = run !== null && run.ok ? run : null + const matches = ok?.match?.matches ?? [] + const replaced = ok?.replace ?? null + const output = replaced?.output ?? '' + + const inputNoWrap = useMemo(() => hasLongLine(state.text), [state.text]) + const outputNoWrap = useMemo(() => hasLongLine(output), [output]) + + const textRef = useRef(null) + + /** Selects a match in the text, where it can be seen in context. */ + function selectMatch(m: RegexMatch) { + const ta = textRef.current?.querySelector('textarea') + // The table may trail the text by a keystroke; its offsets would be stale. + if (!ta || outcome === undefined || ta.value !== outcome.input.text) return + ta.focus() + ta.setSelectionRange(m.start, m.end) + } + + function copyMatches() { + void copyText(matches.map((m) => m.text).join('\n')) + } + + /** A pasted `/pattern/flags` literal fills both fields. */ + function onPatternPaste(e: ClipboardEvent) { + const literal = parseRegexLiteral(e.clipboardData.getData('text')) + const field = e.currentTarget + const replacesAll = field.selectionStart === 0 && field.selectionEnd === field.value.length + if (literal === null || !replacesAll) return + e.preventDefault() + setState((p) => ({ ...p, pattern: literal.pattern, flags: literal.flags })) + } + + useShortcuts([ + { + combo: 'mod+shift+c', + label: state.mode === 'match' ? 'Copy matches' : 'Copy result', + group: 'Regex Tester', + scope: 'tool', + whileTyping: true, + run: () => { + if (state.mode === 'match') copyMatches() + else void copyText(output) + }, + }, + ]) + + let right: ReactNode + if (state.mode === 'replace' && ok !== null) { + right = ( + + ) + } else { + right = ( + + ) + } + + const count = state.mode === 'match' ? matches.length : (replaced?.count ?? 0) + const truncated = ok?.match?.truncated === true + + return ( + + setState((p) => ({ ...p, mode }))} + /> + setState((p) => ({ ...p, flags: toggleFlag(p.flags, f) }))} /> + {state.mode === 'match' ? ( + + ) : ( + + )} + + + } + > +
+
+
+ + + / + + setState((p) => ({ ...p, pattern: e.target.value }))} + onPaste={onPatternPaste} + placeholder="(?\d{4})-(?\d{2}) — or paste /pattern/flags" + aria-invalid={failed !== null} + aria-describedby={failed === null ? undefined : 'regex-error'} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + /> + + /{state.flags} + +
+ + {state.mode === 'replace' && ( +
+ + setState((p) => ({ ...p, replacement: e.target.value }))} + placeholder="$/$ — $1, $, $& (whole match), $$ (a literal $)" + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + /> +
+ )} + + {failed !== null && ( +

+ + {failed.message} +

+ )} + {state.mode === 'replace' && + replaced?.issues.map((issue) => ( +

+ + {issue.message} +

+ ))} +
+ +
+ + setState((p) => ({ ...p, text: e.target.value }))} + wrap={inputNoWrap ? 'off' : undefined} + placeholder="Paste the text to test against…" + aria-label="Text" + /> +
+ {ok !== null && ( + + {truncated ? `${formatCount(MATCH_LIMIT)}+` : formatCount(count)}{' '} + {state.mode === 'match' + ? count === 1 && !truncated + ? 'match' + : 'matches' + : count === 1 + ? 'replacement' + : 'replacements'} + + )} + {ok !== null && ok.groupNames.length > 0 && ( + + {ok.groupNames.length} {ok.groupNames.length === 1 ? 'group' : 'groups'} + + )} + {truncated && Showing the first {formatCount(MATCH_LIMIT)}} + {ok !== null && state.mode === 'match' && !state.flags.includes('g') && state.text !== '' && ( + first match only (no g) + )} + {job.status === 'slow' && ( + <> + + Still running after 2s + + + + )} + {job.status === 'cancelled' && ( + + Cancelled — showing the last finished result + + )} + {job.status === 'error' && {job.error}} +
+
+ } + right={
{right}
} + /> +
+ +
+ ) +} + +function FlagToggles({ flags, onToggle }: { flags: string; onToggle: (flag: RegexFlag) => void }) { + return ( +
+ {REGEX_FLAGS.map((f) => { + const on = flags.includes(f) + return ( + + ) + })} +
+ ) +} + +function MatchTable({ + matches, + groupNames, + onSelect, + empty, +}: { + matches: RegexMatch[] + groupNames: Array + onSelect: (m: RegexMatch) => void + empty: string +}) { + const rowHeight = useNarrow() ? ROW_NARROW : ROW_WIDE + const scrollRef = useRef(null) + // The rule guards React Compiler memoisation, and this build does not run the + // compiler. TanStack Virtual is the documented exception it flags. + // oxlint-disable-next-line react/incompatible-library + const virtualizer = useVirtualizer({ + count: matches.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan: 20, + }) + + useEffect(() => { + virtualizer.measure() + }, [rowHeight, virtualizer]) + + if (matches.length === 0) return

{empty}

+ + return ( +
+
+
+ + # + + Ln:Col + Match + {groupNames.length > 0 ? 'Groups' : ''} +
+
+ {virtualizer.getVirtualItems().map((item) => { + const m = matches[item.index]! + return ( + + ) + })} +
+
+
+ ) +} diff --git a/src/tools/regex/regex.worker.ts b/src/tools/regex/regex.worker.ts new file mode 100644 index 0000000..d3a6478 --- /dev/null +++ b/src/tools/regex/regex.worker.ts @@ -0,0 +1,17 @@ +import { expose } from 'comlink' +import { runRegex, type RegexRun, type RegexRunInput } from '@fadeltd/devtools-core/regex' + +/** + * Every use of the user's pattern happens here, compiling included: a pattern + * like (a+)+$ backtracks without end, never yields, and only terminate() can + * stop it. + */ +const api = { + run(input: RegexRunInput): RegexRun { + return runRegex(input) + }, +} + +export type RegexApi = typeof api + +expose(api)