Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f0181cd

Browse files
Stale config cleanup commands (#3)
* checks in past 30 days to hover text * diagnostic check for stale configs * quick action to delete single reference * update contract * replace with default values * toggle diagnostics in a setting * add bulk cleanup callable command * support getExperiment api calls * add stale check reason Co-authored-by: Vijaye Raji <[email protected]>
1 parent fbbe4f1 commit f0181cd

File tree

7 files changed

+391
-26
lines changed

7 files changed

+391
-26
lines changed

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@
6161
"default": false,
6262
"description": "Whether to display CodeLens tooltips."
6363
},
64+
"statsig.textEditor.enableDiagnostics": {
65+
"type": "boolean",
66+
"default": true,
67+
"description": "Whether to display diagnostic info."
68+
},
6469
"statsig.web.tier": {
6570
"type": "string",
6671
"default": "prod",
@@ -142,6 +147,10 @@
142147
{
143148
"command": "statsig.feelingLucky",
144149
"title": "I'm feeling lucky"
150+
},
151+
{
152+
"command": "statsig.cleanupStale",
153+
"title": "Statsig: Cleanup stale gates/configs"
145154
}
146155
],
147156
"menus": {

src/extension.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import ConfigHoverProvider from './providers/ConfigHoverProvider';
1515
import getExtensionConfig from './state/getExtensionConfig';
1616
import ConfigCodeLensProvider from './providers/ConfigCodeLensProvider';
1717
import { subscribeToDocumentChanges } from './providers/diagnostics';
18+
import {
19+
registerCommands,
20+
ConfigCodeActionProvider,
21+
} from './providers/ConfigCodeActionProvider';
1822

1923
export function activate(context: vsc.ExtensionContext): void {
2024
const config = getExtensionConfig();
@@ -61,7 +65,13 @@ export function activate(context: vsc.ExtensionContext): void {
6165
staleConfigDiagnostic,
6266
);
6367

64-
subscribeToDocumentChanges(context, staleConfigDiagnostic);
68+
if (config.textEditor.enableDiagnostics) {
69+
const staleConfigDiagnostic = vsc.languages.createDiagnosticCollection(
70+
'statsig.stale-config',
71+
);
72+
context.subscriptions.push(staleConfigDiagnostic);
73+
subscribeToDocumentChanges(context, staleConfigDiagnostic);
74+
}
6575

6676
if (config.textEditor.enableHoverTooltips) {
6777
vsc.languages.registerHoverProvider(
@@ -70,6 +80,15 @@ export function activate(context: vsc.ExtensionContext): void {
7080
);
7181
}
7282

83+
vsc.languages.registerCodeActionsProvider(
84+
{ scheme: 'file' },
85+
new ConfigCodeActionProvider(),
86+
{
87+
providedCodeActionKinds: ConfigCodeActionProvider.providedCodeActionKinds,
88+
},
89+
);
90+
registerCommands(context);
91+
7392
void fetchConfigs.run({
7493
throttle: true,
7594
silent: true,

src/lib/languageUtils.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,101 @@
1-
export const CONFIG_NAME_REGEX = /[a-zA-Z0-9_ ]+/;
2-
export const CONFIG_NAME_WITH_QUOTES_REGEX = /["'`][a-zA-Z0-9_ ]+["'`]/;
1+
export const CONFIG_NAME_REGEX = /[a-zA-Z0-9_ ]+/g;
2+
export const CONFIG_NAME_WITH_QUOTES_REGEX = /["'`][a-zA-Z0-9_ ]+["'`]/g;
3+
4+
const SUPPORTED_LANGUAGES = ['javascript', 'typescript'] as const;
5+
export type SupportedLanguageType = typeof SUPPORTED_LANGUAGES[number];
6+
export const SUPPORTED_FILE_EXTENSIONS = ['js', 'ts'];
7+
8+
export function isLanguageSupported(language: string): boolean {
9+
return (SUPPORTED_LANGUAGES as unknown as string[]).includes(language);
10+
}
11+
12+
export function getStatsigAPICheckGateRegex(
13+
language: SupportedLanguageType,
14+
configName: string,
15+
): RegExp {
16+
switch (language) {
17+
case 'javascript':
18+
case 'typescript':
19+
return new RegExp(
20+
`[a-zA-Z0-9_]+[.]checkGate\\([^)]*["'\`]${configName}["'\`][^)]*\\)`,
21+
'g',
22+
);
23+
}
24+
}
25+
26+
export function getStatsigAPIGetConfigRegex(
27+
language: SupportedLanguageType,
28+
configName: string,
29+
): RegExp {
30+
switch (language) {
31+
case 'javascript':
32+
case 'typescript':
33+
return new RegExp(
34+
`[a-zA-Z0-9_]+[.]getConfig\\([^)]*["'\`]${configName}["'\`][^)]*\\)`,
35+
'g',
36+
);
37+
}
38+
}
39+
40+
export function getStatsigAPIGetExperimentRegex(
41+
language: SupportedLanguageType,
42+
configName: string,
43+
): RegExp {
44+
switch (language) {
45+
case 'javascript':
46+
case 'typescript':
47+
return new RegExp(
48+
`[a-zA-Z0-9_]+[.]getExperiment\\([^)]*["'\`]${configName}["'\`][^)]*\\)`,
49+
'g',
50+
);
51+
}
52+
}
53+
54+
export function getVariableAssignmentRegex(
55+
language: SupportedLanguageType,
56+
configName: string,
57+
): RegExp {
58+
switch (language) {
59+
case 'javascript':
60+
case 'typescript':
61+
return new RegExp(`[=][\\s]?["'\`]${configName}["'\`]`, 'g');
62+
}
63+
}
64+
65+
export function checkGateReplacement(language: SupportedLanguageType): string {
66+
switch (language) {
67+
case 'javascript':
68+
case 'typescript':
69+
return 'false';
70+
}
71+
}
72+
73+
export function getConfigReplacement(language: SupportedLanguageType): string {
74+
switch (language) {
75+
case 'javascript':
76+
case 'typescript':
77+
return '{}';
78+
}
79+
}
80+
81+
export function getExperimentReplacement(
82+
language: SupportedLanguageType,
83+
): string {
84+
switch (language) {
85+
case 'javascript':
86+
case 'typescript':
87+
return '{}';
88+
}
89+
}
90+
91+
export function nthIndexOf(base: string, search: string, n = 1): number {
92+
const length = base.length;
93+
let index = -1;
94+
while (n-- && index++ < length) {
95+
index = base.indexOf(search, index);
96+
if (index < 0) {
97+
break;
98+
}
99+
}
100+
return index;
101+
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/* eslint-disable @typescript-eslint/prefer-regexp-exec */
2+
import * as vsc from 'vscode';
3+
import {
4+
checkGateReplacement,
5+
getConfigReplacement,
6+
getExperimentReplacement,
7+
getStatsigAPICheckGateRegex,
8+
getStatsigAPIGetConfigRegex,
9+
getStatsigAPIGetExperimentRegex,
10+
getVariableAssignmentRegex,
11+
isLanguageSupported,
12+
nthIndexOf,
13+
SupportedLanguageType,
14+
SUPPORTED_FILE_EXTENSIONS,
15+
} from '../lib/languageUtils';
16+
import { DiagnosticCode, findStaleConfigs } from './diagnostics';
17+
18+
type CodeActionType = {
19+
command: string;
20+
title: string;
21+
kind: vsc.CodeActionKind;
22+
};
23+
24+
export const CODE_ACTIONS: Record<string, CodeActionType> = {
25+
removeStaleConfig: {
26+
command: 'remove-stale-config',
27+
title: 'Cleanup',
28+
kind: vsc.CodeActionKind.QuickFix,
29+
},
30+
};
31+
32+
export class ConfigCodeActionProvider implements vsc.CodeActionProvider {
33+
public static readonly providedCodeActionKinds = [
34+
vsc.CodeActionKind.QuickFix,
35+
];
36+
37+
provideCodeActions(
38+
doc: vsc.TextDocument,
39+
range: vsc.Range | vsc.Selection,
40+
context: vsc.CodeActionContext,
41+
_token: vsc.CancellationToken,
42+
): vsc.ProviderResult<(vsc.CodeAction | vsc.Command)[]> {
43+
if (!isLanguageSupported(doc.languageId)) {
44+
return [];
45+
}
46+
47+
const actions: vsc.CodeAction[] = [];
48+
context.diagnostics
49+
.filter((diagnostic) => diagnostic.code === DiagnosticCode.staleCheck)
50+
.forEach((diagnostic) => {
51+
actions.push(
52+
this.newCodeAction(doc, range, diagnostic, {
53+
...CODE_ACTIONS.removeStaleConfig,
54+
}),
55+
);
56+
});
57+
return actions;
58+
}
59+
60+
private newCodeAction(
61+
doc: vsc.TextDocument,
62+
range: vsc.Range | vsc.Selection,
63+
diagnostic: vsc.Diagnostic,
64+
type: CodeActionType,
65+
): vsc.CodeAction {
66+
const action = new vsc.CodeAction(type.title, type.kind);
67+
const edit = new vsc.WorkspaceEdit();
68+
const configName = doc.getText(range);
69+
const success = ConfigCodeActionProvider.handleEdit(
70+
doc,
71+
edit,
72+
[configName],
73+
range,
74+
);
75+
action.edit = edit;
76+
action.command = {
77+
command: type.command,
78+
title: action.title,
79+
arguments: [configName, success],
80+
};
81+
action.diagnostics = [diagnostic];
82+
action.isPreferred = true;
83+
return action;
84+
}
85+
86+
public static handleEdit(
87+
doc: vsc.TextDocument,
88+
edit: vsc.WorkspaceEdit,
89+
configs: string[],
90+
range?: vsc.Range | vsc.Selection,
91+
): boolean {
92+
let changesMade = false;
93+
let searchableText = doc.getText();
94+
let offset = 0;
95+
if (range) {
96+
const line = doc.lineAt(range.start.line);
97+
searchableText = line.text;
98+
offset = doc.offsetAt(line.range.start);
99+
}
100+
const language = doc.languageId as SupportedLanguageType;
101+
const seen: { [key: string]: number } = {};
102+
103+
const uniqueConfigs = new Set(configs);
104+
uniqueConfigs.forEach((config) => {
105+
const addReplacementEdit = (
106+
matches: RegExpMatchArray | null,
107+
replacement: string,
108+
): void => {
109+
if (!matches) {
110+
return;
111+
}
112+
for (const match of matches) {
113+
seen[match] ? ++seen[match] : (seen[match] = 1);
114+
const matchIndex =
115+
nthIndexOf(searchableText, match, seen[match]) + offset;
116+
const matchRange = new vsc.Range(
117+
doc.positionAt(matchIndex),
118+
doc.positionAt(matchIndex + match.length),
119+
);
120+
edit.replace(doc.uri, matchRange, replacement);
121+
changesMade = true;
122+
}
123+
};
124+
const checkGateMatch = searchableText.match(
125+
getStatsigAPICheckGateRegex(language, config),
126+
);
127+
addReplacementEdit(checkGateMatch, checkGateReplacement(language));
128+
129+
const getConfigMatch = searchableText.match(
130+
getStatsigAPIGetConfigRegex(language, config),
131+
);
132+
addReplacementEdit(getConfigMatch, getConfigReplacement(language));
133+
134+
const getExperimentMatch = searchableText.match(
135+
getStatsigAPIGetExperimentRegex(language, config),
136+
);
137+
addReplacementEdit(
138+
getExperimentMatch,
139+
getExperimentReplacement(language),
140+
);
141+
142+
const variableAssignmentMatch = searchableText.match(
143+
getVariableAssignmentRegex(language, config),
144+
);
145+
if (variableAssignmentMatch) {
146+
for (const match of variableAssignmentMatch) {
147+
seen[match] ? ++seen[match] : (seen[match] = 1);
148+
const matchIndex =
149+
nthIndexOf(searchableText, match, seen[match]) + offset;
150+
const line = doc.lineAt(doc.positionAt(matchIndex).line);
151+
edit.delete(doc.uri, line.rangeIncludingLineBreak);
152+
changesMade = true;
153+
}
154+
}
155+
});
156+
157+
return changesMade;
158+
}
159+
}
160+
161+
const removeStaleConfigCommandHandler = (config: string, success: boolean) => {
162+
if (success) {
163+
void vsc.window.showInformationMessage(`Removed ${config}`);
164+
} else {
165+
void vsc.window.showInformationMessage(`Could not remove ${config}`);
166+
}
167+
};
168+
169+
const removeAllStaleConfgsCommandHandler = async () => {
170+
const output = vsc.window.createOutputChannel('Statsig output');
171+
const edit = new vsc.WorkspaceEdit();
172+
const files = await vsc.workspace.findFiles(
173+
`**/*.{${SUPPORTED_FILE_EXTENSIONS.join()}}`,
174+
);
175+
const cleanedFiles = [];
176+
const configCounts: { [key: string]: number } = {};
177+
for (const file of files) {
178+
const doc = await vsc.workspace.openTextDocument(file);
179+
const staleConfigs = findStaleConfigs(doc);
180+
if (staleConfigs.length === 0) {
181+
continue;
182+
}
183+
if (ConfigCodeActionProvider.handleEdit(doc, edit, staleConfigs)) {
184+
staleConfigs.forEach((config) => {
185+
configCounts[config]
186+
? ++configCounts[config]
187+
: (configCounts[config] = 1);
188+
});
189+
cleanedFiles.push(file.path);
190+
output.appendLine(`(success) ${file.path}`);
191+
} else {
192+
output.appendLine(`(failed) ${file.path}`);
193+
}
194+
}
195+
output.appendLine('');
196+
output.appendLine('Summary of Instances Removed: ');
197+
Object.entries(configCounts)
198+
.sort((a, b) => b[1] - a[1])
199+
.forEach((counts) => {
200+
output.appendLine(`${counts[0]}: ${counts[1]}`);
201+
});
202+
const success = await vsc.workspace.applyEdit(edit);
203+
await vsc.workspace.saveAll();
204+
output.show();
205+
if (success) {
206+
await vsc.window.showInformationMessage(
207+
`Cleaned up ${cleanedFiles.length} ${
208+
cleanedFiles.length === 1 ? 'file' : 'files'
209+
}`,
210+
);
211+
} else {
212+
await vsc.window.showInformationMessage(`Something went wrong`);
213+
}
214+
};
215+
216+
export function registerCommands(context: vsc.ExtensionContext): void {
217+
context.subscriptions.push(
218+
vsc.commands.registerCommand(
219+
CODE_ACTIONS.removeStaleConfig.command,
220+
removeStaleConfigCommandHandler,
221+
),
222+
vsc.commands.registerCommand(
223+
'statsig.cleanupStale',
224+
removeAllStaleConfgsCommandHandler,
225+
),
226+
);
227+
}

0 commit comments

Comments
 (0)