Skip to content

Commit 812f130

Browse files
authored
improvement(utils): share escapeRegExp, compareStrings and isRecordLike (#8052)
* improvement(utils): add escapeRegExp and compareStrings to @sim/utils/string * improvement(utils): replace nine local escapeRegExp copies with the shared helper * improvement(utils): adopt shared compareStrings and isRecordLike over local copies * docs: document the shared escapeRegExp, compareStrings, and isRecordLike helpers * improvement(utils): finish the escapeRegExp sweep and share the metacharacter class Replaces the nine inline copies of the escape body the name-based sweep missed, folds the catalog cursor comparator into compareStrings, and gives linear-regex its metacharacter test from the same source the escaper uses.
1 parent 04096b6 commit 812f130

54 files changed

Lines changed: 254 additions & 242 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/global.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
5151
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
5252
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
5353
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
54+
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
5455
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
56+
- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
57+
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there
5558
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
5659
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
5760

.cursor/rules/global.mdc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
5454
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
5555
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
5656
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
57+
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
5758
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
59+
- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
60+
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there
5861
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
5962
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
6063

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ You are a professional software engineer. All code must follow best practices: a
1616
- `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — extract message string from unknown caught value; never write `e instanceof Error ? e.message : 'fallback'`
1717
- `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))`
1818
- `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))`
19+
- `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
1920
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis
21+
- `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
22+
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there
2023
- `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline
2124
- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags`
2225
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`

apps/desktop/src/main/local-filesystem.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@sim/desktop-bridge/local-filesystem-limits'
2020
import { generateId } from '@sim/utils/id'
2121
import { isRecordLike } from '@sim/utils/object'
22+
import { escapeRegExp } from '@sim/utils/string'
2223
import { app, dialog, shell } from 'electron'
2324
import micromatch from 'micromatch'
2425
import safeRegex from 'safe-regex2'
@@ -1114,7 +1115,7 @@ export class LocalFilesystemService {
11141115
regex =
11151116
rawPattern !== undefined
11161117
? new RegExp(expression, ignoreCase ? 'i' : '')
1117-
: new RegExp(expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '')
1118+
: new RegExp(escapeRegExp(expression), ignoreCase ? 'i' : '')
11181119
} catch {
11191120
// An empty result set would tell the model the string appears nowhere in
11201121
// the user's files — a factual claim it will act on, when in truth the

apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock('@/app/(landing)/comparisons/components/comparison-cards', () => ({
3838
ComparisonCards: () => null,
3939
}))
4040

41+
import { escapeRegExp } from '@sim/utils/string'
4142
import type { Prose } from '@/lib/compare/data'
4243
import { dustProfile } from '@/lib/compare/data'
4344
import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page'
@@ -64,7 +65,7 @@ function countMatches(markup: string, pattern: RegExp): number {
6465
* against the wrong anchor.
6566
*/
6667
function anchorWrapping(markup: string, text: string): string {
67-
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68+
const escaped = escapeRegExp(text)
6869
return markup.match(new RegExp(`<a [^>]*>${escaped}</a>`))?.[0] ?? ''
6970
}
7071

apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ChipLink } from '@sim/emcn'
2-
import { truncate } from '@sim/utils/string'
2+
import { escapeRegExp, truncate } from '@sim/utils/string'
33
import type { Metadata } from 'next'
44
import Link from 'next/link'
55
import { notFound } from 'next/navigation'
@@ -127,10 +127,6 @@ function sentenceWithTerminalPunctuation(value: string): string {
127127
return /[.!?]$/.test(trimmedValue) ? trimmedValue : `${trimmedValue}.`
128128
}
129129

130-
function escapeRegex(value: string): string {
131-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
132-
}
133-
134130
/**
135131
* Server-side rewrite of bare integration names in a curated template prompt
136132
* to `@`-mention form (`Slack` → `@Slack`) so the prompt chips with brand
@@ -152,7 +148,7 @@ function mentionifyPromptForNames(prompt: string, names: readonly string[]): str
152148
)
153149
if (unique.length === 0) return prompt
154150
const regex = new RegExp(
155-
`(?<![A-Za-z0-9_@])(${unique.map(escapeRegex).join('|')})(?![A-Za-z0-9_])`,
151+
`(?<![A-Za-z0-9_@])(${unique.map(escapeRegExp).join('|')})(?![A-Za-z0-9_])`,
156152
'gi'
157153
)
158154
return prompt.replace(regex, (match) => `@${match}`)

apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { escapeRegExp } from '@sim/utils/string'
2+
13
interface SearchHighlightProps {
24
text: string
35
searchQuery: string
@@ -18,7 +20,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
1820
.trim()
1921
.split(/\s+/)
2022
.filter((term) => term.length > 0)
21-
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
23+
.map(escapeRegExp)
2224

2325
if (searchTerms.length === 0) {
2426
return <span className={className}>{text}</span>

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { toast } from '@sim/emcn'
33
import { assessTextPaste, PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste'
4+
import { escapeRegExp } from '@sim/utils/string'
45
import {
56
attachSelectionContextToClipboard,
67
readSelectionContextFromClipboard,
@@ -28,7 +29,6 @@ import {
2829
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
2930
import {
3031
areContextsEqual,
31-
escapeRegex,
3232
filterContextsPresentInMessage,
3333
prepareContextForInsert,
3434
restoreSkillTriggerText,
@@ -369,7 +369,7 @@ export function usePromptEditor({
369369

370370
const labelIsUsed = (candidate: string): boolean => {
371371
if (selectedContexts.some((selected) => selected.label === candidate)) return true
372-
return new RegExp(`(^|\\s)@${escapeRegex(candidate)}(?![A-Za-z0-9_])`).test(currentValue)
372+
return new RegExp(`(^|\\s)@${escapeRegExp(candidate)}(?![A-Za-z0-9_])`).test(currentValue)
373373
}
374374

375375
while (labelIsUsed(label)) {

apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useCallback, useMemo, useRef } from 'react'
2-
import {
3-
escapeRegex,
4-
SKILL_CHIP_TRIGGER,
5-
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
2+
import { escapeRegExp } from '@sim/utils/string'
3+
import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
64
import type { McpServer } from '@/hooks/queries/mcp'
75
import type { SkillDefinition } from '@/hooks/queries/skills'
86
import type { ChatContext } from '@/stores/panel'
@@ -104,8 +102,8 @@ export function useSkillAutoMention({
104102
// Match either trigger: the typed '/' or the stored sentinel, so both fresh
105103
// input and pasted/restored chips resolve. The trigger group is the match's
106104
// first char (`text[match.index]`); group 1 is the skill name.
107-
const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})`
108-
const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])`
105+
const trigger = `(?:/|${escapeRegExp(SKILL_CHIP_TRIGGER)})`
106+
const pattern = `${trigger}(${names.map(escapeRegExp).join('|')})(?![A-Za-z0-9_-])`
109107
return { regex: new RegExp(pattern, 'gi'), byName }
110108
}, [skills, mcpServers])
111109

apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useMemo } from 'react'
44
import { cn } from '@sim/emcn'
5+
import { escapeRegExp } from '@sim/utils/string'
56
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
67
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
78
import { getIntegrationMatcher } from '@/blocks/integration-matcher'
@@ -22,10 +23,6 @@ interface UserMessageContentProps {
2223
compact?: boolean
2324
}
2425

25-
function escapeRegex(str: string): string {
26-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
27-
}
28-
2926
interface MentionRange {
3027
start: number
3128
end: number
@@ -54,7 +51,7 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men
5451
const ctx = withResolvedBlockType(rawCtx)
5552
const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@'
5653
const token = `${prefix}${ctx.label}`
57-
const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g')
54+
const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(\\s|$)`, 'g')
5855
let match: RegExpExecArray | null
5956
while ((match = pattern.exec(text)) !== null) {
6057
const leadingSpace = match[1]

0 commit comments

Comments
 (0)