Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `$<name>`, `\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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/regex/compile.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>]> = [
['abc', '', []],
['(a)(b)', '', [null, null]],
['(?<year>\\d{4})-(\\d{2})', '', ['year', null]],
['(?:a)(?=b)(?!c)(?<=d)(?<!e)(f)', '', [null]],
['\\(a\\)', '', []],
['[(](a)', '', [null]],
['[\\]()](b)', '', [null]],
['[a[(]](c)', 'v', [null]],
['(?<a>x)|(?<a>y)', '', ['a', 'a']],
['((a)(?<n>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)
}
})
})
121 changes: 121 additions & 0 deletions packages/core/src/regex/compile.ts
Original file line number Diff line number Diff line change
@@ -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<RegexFlag, { name: string; description: string }> = {
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<string>()
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<string | null> {
const names: Array<string | null> = []
// 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
}
4 changes: 4 additions & 0 deletions packages/core/src/regex/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './compile.ts'
export * from './match.ts'
export * from './replace.ts'
export * from './run.ts'
73 changes: 73 additions & 0 deletions packages/core/src/regex/match.test.ts
Original file line number Diff line number Diff line change
@@ -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', /(?<year>\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)?', '(?<x>[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)
},
),
)
})
})
Loading
Loading