Skip to content

Commit 2d07d11

Browse files
Bulk scope test recorder (#2383)
1. Add support for new scope facets to a particular language. eg: `scopeSupportFacets\typescript.ts` 2. Issue first command to show all unimplemented facets * Select language * New untitled document opens with all `supported` facets for the language that are missing `.scope` files 3. Edit document with any number of fixtures 4. Issues second command to create multiple scope test fixtures ``` [[typescript]] [command] - A command, for example Talon spoken command or bash hello --- ``` Thoughts: * Do we want to close the document after it's read? If yes do we have something on the ide for this already or do I need to add it? * I'm not one hundred percent sure on the command identifiers * Regarding docs. Should I add a new heading in the exist [test case recorder file](https://github.com/cursorless-dev/cursorless/blob/ec694b754cd7169f792e6d2bb4028ab17a83bf4e/docs/contributing/test-case-recorder.md) or should I start a new one for this format? ## Checklist - [/] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [ ] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [/] I have not broken the cheatsheet --------- Co-authored-by: Pokey Rule <[email protected]>
1 parent 1183dd3 commit 2d07d11

File tree

12 files changed

+314
-133
lines changed

12 files changed

+314
-133
lines changed

cursorless-talon-dev/src/cursorless_dev.talon

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ tag: user.cursorless
1010
user.run_rpc_command("cursorless.recordTestCase")
1111
{user.cursorless_homophone} record one:
1212
user.run_rpc_command("cursorless.recordOneTestCaseThenPause")
13+
{user.cursorless_homophone} record scope:
14+
user.run_rpc_command("cursorless.recordScopeTests.showUnimplementedFacets")
15+
{user.cursorless_homophone} save scope:
16+
user.run_rpc_command("cursorless.recordScopeTests.saveActiveDocument")
1317
{user.cursorless_homophone} pause:
1418
user.run_rpc_command("cursorless.pauseRecording")
1519
{user.cursorless_homophone} resume:

packages/common/src/cursorlessCommandIds.ts

+8
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export const cursorlessCommandIds = [
4040
"cursorless.recordTestCase",
4141
"cursorless.recordOneTestCaseThenPause",
4242
"cursorless.resumeRecording",
43+
"cursorless.recordScopeTests.showUnimplementedFacets",
44+
"cursorless.recordScopeTests.saveActiveDocument",
4345
"cursorless.showCheatsheet",
4446
"cursorless.showDocumentation",
4547
"cursorless.showQuickPick",
@@ -70,6 +72,12 @@ export const cursorlessCommandDescriptions: Record<
7072
["cursorless.resumeRecording"]: new VisibleCommand(
7173
"Resume test case recording",
7274
),
75+
["cursorless.recordScopeTests.showUnimplementedFacets"]: new VisibleCommand(
76+
"Bulk record unimplemented scope facets",
77+
),
78+
["cursorless.recordScopeTests.saveActiveDocument"]: new VisibleCommand(
79+
"Bulk save scope tests for the active document",
80+
),
7381
["cursorless.showDocumentation"]: new VisibleCommand("Show documentation"),
7482
["cursorless.showScopeVisualizer"]: new VisibleCommand(
7583
"Show the scope visualizer",

packages/common/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export * from "./testUtil/shouldUpdateFixtures";
6969
export * from "./testUtil/TestCaseSnapshot";
7070
export * from "./testUtil/serializeTestFixture";
7171
export * from "./testUtil/asyncSafety";
72+
export * from "./testUtil/getScopeTestPathsRecursively";
7273
export * from "./util/typeUtils";
7374
export * from "./ide/types/hatStyles.types";
7475
export * from "./errors";
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { StringRecord } from "../types/StringRecord";
12
import { cScopeSupport } from "./c";
23
import { clojureScopeSupport } from "./clojure";
34
import { cppScopeSupport } from "./cpp";
@@ -27,36 +28,34 @@ import { typescriptreactScopeSupport } from "./typescriptreact";
2728
import { xmlScopeSupport } from "./xml";
2829
import { yamlScopeSupport } from "./yaml";
2930

30-
export const languageScopeSupport: Record<
31-
string,
32-
LanguageScopeSupportFacetMap
33-
> = {
34-
c: cScopeSupport,
35-
clojure: clojureScopeSupport,
36-
cpp: cppScopeSupport,
37-
csharp: csharpScopeSupport,
38-
css: cssScopeSupport,
39-
go: goScopeSupport,
40-
html: htmlScopeSupport,
41-
java: javaScopeSupport,
42-
javascript: javascriptScopeSupport,
43-
javascriptreact: javascriptScopeSupport,
44-
json: jsonScopeSupport,
45-
jsonc: jsoncScopeSupport,
46-
jsonl: jsonlScopeSupport,
47-
latex: latexScopeSupport,
48-
lua: luaScopeSupport,
49-
markdown: markdownScopeSupport,
50-
php: phpScopeSupport,
51-
python: pythonScopeSupport,
52-
ruby: rubyScopeSupport,
53-
rust: rustScopeSupport,
54-
scala: scalaScopeSupport,
55-
scm: scmScopeSupport,
56-
scss: scssScopeSupport,
57-
talon: talonScopeSupport,
58-
typescript: typescriptScopeSupport,
59-
typescriptreact: typescriptreactScopeSupport,
60-
xml: xmlScopeSupport,
61-
yaml: yamlScopeSupport,
62-
};
31+
export const languageScopeSupport: StringRecord<LanguageScopeSupportFacetMap> =
32+
{
33+
c: cScopeSupport,
34+
clojure: clojureScopeSupport,
35+
cpp: cppScopeSupport,
36+
csharp: csharpScopeSupport,
37+
css: cssScopeSupport,
38+
go: goScopeSupport,
39+
html: htmlScopeSupport,
40+
java: javaScopeSupport,
41+
javascript: javascriptScopeSupport,
42+
javascriptreact: javascriptScopeSupport,
43+
json: jsonScopeSupport,
44+
jsonc: jsoncScopeSupport,
45+
jsonl: jsonlScopeSupport,
46+
latex: latexScopeSupport,
47+
lua: luaScopeSupport,
48+
markdown: markdownScopeSupport,
49+
php: phpScopeSupport,
50+
python: pythonScopeSupport,
51+
ruby: rubyScopeSupport,
52+
rust: rustScopeSupport,
53+
scala: scalaScopeSupport,
54+
scm: scmScopeSupport,
55+
scss: scssScopeSupport,
56+
talon: talonScopeSupport,
57+
typescript: typescriptScopeSupport,
58+
typescriptreact: typescriptreactScopeSupport,
59+
xml: xmlScopeSupport,
60+
yaml: yamlScopeSupport,
61+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { readFileSync } from "node:fs";
2+
import { groupBy, type Dictionary } from "lodash";
3+
import {
4+
getScopeTestConfigPaths,
5+
getScopeTestPaths,
6+
type ScopeTestPath,
7+
} from "./getFixturePaths";
8+
9+
export interface ScopeTestConfig {
10+
imports?: string[];
11+
skip?: boolean;
12+
}
13+
14+
export function getScopeTestPathsRecursively(): ScopeTestPath[] {
15+
const configPaths = getScopeTestConfigPaths();
16+
const configs = readConfigFiles(configPaths);
17+
const testPathsRaw = getScopeTestPaths();
18+
const languagesRaw = groupBy(testPathsRaw, (test) => test.languageId);
19+
const result: ScopeTestPath[] = [];
20+
21+
// Languages without any tests still needs to be included in case they have an import
22+
for (const languageId of Object.keys(configs)) {
23+
if (!languagesRaw[languageId]) {
24+
languagesRaw[languageId] = [];
25+
}
26+
}
27+
28+
for (const languageId of Object.keys(languagesRaw)) {
29+
const config = configs[languageId];
30+
31+
// This 'language' only exists to be imported by other
32+
if (config?.skip) {
33+
continue;
34+
}
35+
36+
const testPathsForLanguage: ScopeTestPath[] = [];
37+
addTestPathsForLanguageRecursively(
38+
languagesRaw,
39+
configs,
40+
testPathsForLanguage,
41+
new Set(),
42+
languageId,
43+
);
44+
for (const test of testPathsForLanguage) {
45+
const name =
46+
languageId === test.languageId
47+
? test.name
48+
: `${test.name.replace(`/${test.languageId}/`, `/${languageId}/`)} (${test.languageId})`;
49+
result.push({
50+
...test,
51+
languageId,
52+
name,
53+
});
54+
}
55+
}
56+
57+
return result;
58+
}
59+
60+
function addTestPathsForLanguageRecursively(
61+
languages: Dictionary<ScopeTestPath[]>,
62+
configs: Record<string, ScopeTestConfig | undefined>,
63+
result: ScopeTestPath[],
64+
usedLanguageIds: Set<string>,
65+
languageId: string,
66+
): void {
67+
if (usedLanguageIds.has(languageId)) {
68+
return;
69+
}
70+
71+
if (!languages[languageId]) {
72+
throw Error(`No test paths found for language ${languageId}`);
73+
}
74+
75+
result.push(...languages[languageId]);
76+
usedLanguageIds.add(languageId);
77+
78+
const config = configs[languageId];
79+
const importLanguageIds = config?.imports ?? [];
80+
81+
for (const langImport of importLanguageIds) {
82+
addTestPathsForLanguageRecursively(
83+
languages,
84+
configs,
85+
result,
86+
usedLanguageIds,
87+
langImport,
88+
);
89+
}
90+
}
91+
92+
function readConfigFiles(
93+
configPaths: { languageId: string; path: string }[],
94+
): Record<string, ScopeTestConfig> {
95+
const result: Record<string, ScopeTestConfig> = {};
96+
for (const p of configPaths) {
97+
const content = readFileSync(p.path, "utf8");
98+
result[p.languageId] = JSON.parse(content) as ScopeTestConfig;
99+
}
100+
return result;
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type StringRecord<T> = Partial<Record<string, T>>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {
2+
ScopeSupportFacetLevel,
3+
getScopeTestPathsRecursively,
4+
getScopeTestsDirPath,
5+
groupBy,
6+
languageScopeSupport,
7+
scopeSupportFacetInfos,
8+
showInfo,
9+
type IDE,
10+
type ScopeSupportFacet,
11+
} from "@cursorless/common";
12+
import * as fs from "node:fs";
13+
import * as fsPromises from "node:fs/promises";
14+
import * as path from "node:path";
15+
16+
export class ScopeTestRecorder {
17+
constructor(private ide: IDE) {
18+
this.showUnimplementedFacets = this.showUnimplementedFacets.bind(this);
19+
this.saveActiveDocument = this.saveActiveDocument.bind(this);
20+
}
21+
22+
async showUnimplementedFacets() {
23+
const languageId = await this.languageSelection();
24+
25+
if (languageId == null) {
26+
return;
27+
}
28+
29+
const supportedScopeFacets = getSupportedScopeFacets(languageId);
30+
const existingScopeTestFacets = getExistingScopeFacetTest(languageId);
31+
32+
const missingScopeFacets = supportedScopeFacets.filter(
33+
(facet) => !existingScopeTestFacets.has(facet),
34+
);
35+
36+
let currentSnippetPlaceholder = 1;
37+
const missingScopeFacetRows = missingScopeFacets.map(
38+
(facet) =>
39+
`[${facet}] - ${scopeSupportFacetInfos[facet].description}\n$${currentSnippetPlaceholder++}\n---\n`,
40+
);
41+
const header = `[[${languageId}]]\n\n`;
42+
const snippetText = `${header}${missingScopeFacetRows.join("\n")}`;
43+
44+
const editor = await this.ide.openUntitledTextDocument({
45+
language: "markdown",
46+
});
47+
48+
const editableEditor = this.ide.getEditableTextEditor(editor);
49+
await editableEditor.insertSnippet(snippetText);
50+
}
51+
52+
async saveActiveDocument() {
53+
const text = this.ide.activeTextEditor?.document.getText() ?? "";
54+
const matchLanguageId = text.match(/^\[\[(\w+)\]\]\n/);
55+
56+
if (matchLanguageId == null) {
57+
throw Error(`Can't match language id`);
58+
}
59+
60+
const languageId = matchLanguageId[1];
61+
const restText = text.slice(matchLanguageId[0].length);
62+
63+
const parts = restText
64+
.split(/^---$/gm)
65+
.map((p) => p.trimStart())
66+
.filter(Boolean);
67+
68+
const facetsToAdd: { facet: string; content: string }[] = [];
69+
70+
for (const part of parts) {
71+
const match = part.match(/^\[([\w.]+)\].*\n([\s\S]*)$/);
72+
const facet = match?.[1];
73+
const content = match?.[2] ?? "";
74+
75+
if (facet == null) {
76+
throw Error(`Invalid pattern '${part}'`);
77+
}
78+
79+
if (!content.trim()) {
80+
continue;
81+
}
82+
83+
facetsToAdd.push({ facet, content });
84+
}
85+
86+
const langDirectory = path.join(getScopeTestsDirPath(), languageId);
87+
88+
await fsPromises.mkdir(langDirectory, { recursive: true });
89+
90+
for (const { facet, content } of facetsToAdd) {
91+
const fullContent = `${content}---\n`;
92+
let filePath = path.join(langDirectory, `${facet}.scope`);
93+
let i = 2;
94+
95+
while (fs.existsSync(filePath)) {
96+
filePath = path.join(langDirectory, `${facet}${i++}.scope`);
97+
}
98+
99+
await fsPromises.writeFile(filePath, fullContent, "utf-8");
100+
}
101+
102+
await showInfo(
103+
this.ide.messages,
104+
"scopeTestsSaved",
105+
`${facetsToAdd.length} scope tests saved for language '${languageId}`,
106+
);
107+
}
108+
109+
private languageSelection() {
110+
const languageIds = Object.keys(languageScopeSupport);
111+
languageIds.sort();
112+
return this.ide.showQuickPick(languageIds, {
113+
title: "Select language to record scope tests for",
114+
});
115+
}
116+
}
117+
118+
function getSupportedScopeFacets(languageId: string): ScopeSupportFacet[] {
119+
const scopeSupport = languageScopeSupport[languageId];
120+
121+
if (scopeSupport == null) {
122+
throw Error(`Missing scope support for language '${languageId}'`);
123+
}
124+
125+
const scopeFacets = Object.keys(scopeSupport) as ScopeSupportFacet[];
126+
127+
return scopeFacets.filter(
128+
(facet) => scopeSupport[facet] === ScopeSupportFacetLevel.supported,
129+
);
130+
}
131+
132+
function getExistingScopeFacetTest(languageId: string): Set<string> {
133+
const testPaths = getScopeTestPathsRecursively();
134+
const languages = groupBy(testPaths, (test) => test.languageId);
135+
const testPathsForLanguage = languages.get(languageId) ?? [];
136+
const facets = testPathsForLanguage.map((test) => test.facet);
137+
return new Set(facets);
138+
}

packages/cursorless-engine/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from "./CommandRunner";
1111
export * from "./CommandHistory";
1212
export * from "./CommandHistoryAnalyzer";
1313
export * from "./util/grammarHelpers";
14+
export * from "./ScopeTestRecorder";

0 commit comments

Comments
 (0)