@mrclrchtr/supi-tree-sitter 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/node_modules/@mrclrchtr/supi-core/README.md +107 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +44 -0
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +85 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +85 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +86 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +8 -3
- package/src/api.ts +5 -1
- package/src/index.ts +5 -1
- package/src/session/runtime.ts +3 -2
- package/src/session/service-registry.ts +30 -0
- package/src/session/session.ts +16 -8
- package/src/tool/action-specs.ts +92 -0
- package/src/tool/guidance.ts +12 -3
- package/src/tree-sitter.ts +111 -61
- package/src/types.ts +13 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the public `tree_sitter` action surface.
|
|
3
|
+
*
|
|
4
|
+
* Tool registration, validation, and prompt guidance should derive from these
|
|
5
|
+
* specs so the action list and per-action requirements do not drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export const TREE_SITTER_ACTION_SPECS = [
|
|
8
|
+
{
|
|
9
|
+
name: "outline",
|
|
10
|
+
guidanceGroup: "js-ts-structure",
|
|
11
|
+
languageScope: "js-ts-only",
|
|
12
|
+
requiresPosition: false,
|
|
13
|
+
requiresQuery: false,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "imports",
|
|
17
|
+
guidanceGroup: "js-ts-structure",
|
|
18
|
+
languageScope: "js-ts-only",
|
|
19
|
+
requiresPosition: false,
|
|
20
|
+
requiresQuery: false,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "exports",
|
|
24
|
+
guidanceGroup: "js-ts-structure",
|
|
25
|
+
languageScope: "js-ts-only",
|
|
26
|
+
requiresPosition: false,
|
|
27
|
+
requiresQuery: false,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "node_at",
|
|
31
|
+
guidanceGroup: "node-at",
|
|
32
|
+
languageScope: "all-supported",
|
|
33
|
+
requiresPosition: true,
|
|
34
|
+
requiresQuery: false,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "query",
|
|
38
|
+
guidanceGroup: "query",
|
|
39
|
+
languageScope: "all-supported",
|
|
40
|
+
requiresPosition: false,
|
|
41
|
+
requiresQuery: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "callees",
|
|
45
|
+
guidanceGroup: "callees",
|
|
46
|
+
languageScope: "many-supported",
|
|
47
|
+
requiresPosition: true,
|
|
48
|
+
requiresQuery: false,
|
|
49
|
+
},
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export type TreeSitterAction = (typeof TREE_SITTER_ACTION_SPECS)[number]["name"];
|
|
53
|
+
export type TreeSitterActionSpec = (typeof TREE_SITTER_ACTION_SPECS)[number];
|
|
54
|
+
export type TreeSitterGuidanceGroup = TreeSitterActionSpec["guidanceGroup"];
|
|
55
|
+
|
|
56
|
+
/** Ordered action names for schemas, validation messages, and docs. */
|
|
57
|
+
export const TREE_SITTER_ACTION_NAMES = TREE_SITTER_ACTION_SPECS.map(
|
|
58
|
+
(spec) => spec.name,
|
|
59
|
+
) as readonly TreeSitterAction[];
|
|
60
|
+
|
|
61
|
+
const TREE_SITTER_ACTION_NAME_SET = new Set<string>(TREE_SITTER_ACTION_NAMES);
|
|
62
|
+
const TREE_SITTER_ACTION_SPEC_MAP = new Map<TreeSitterAction, TreeSitterActionSpec>(
|
|
63
|
+
TREE_SITTER_ACTION_SPECS.map((spec) => [spec.name, spec]),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** Check whether a runtime string is a supported `tree_sitter` action. */
|
|
67
|
+
export function isTreeSitterAction(action: string): action is TreeSitterAction {
|
|
68
|
+
return TREE_SITTER_ACTION_NAME_SET.has(action);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Look up the spec for one supported `tree_sitter` action. */
|
|
72
|
+
export function getTreeSitterActionSpec(action: TreeSitterAction): TreeSitterActionSpec {
|
|
73
|
+
const spec = TREE_SITTER_ACTION_SPEC_MAP.get(action);
|
|
74
|
+
if (!spec) {
|
|
75
|
+
throw new Error(`Unknown tree_sitter action: ${action}`);
|
|
76
|
+
}
|
|
77
|
+
return spec;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Get the ordered action names that belong to one prompt-guidance group. */
|
|
81
|
+
export function getTreeSitterActionNamesByGuidanceGroup(
|
|
82
|
+
group: TreeSitterGuidanceGroup,
|
|
83
|
+
): TreeSitterAction[] {
|
|
84
|
+
return TREE_SITTER_ACTION_SPECS.filter((spec) => spec.guidanceGroup === group).map(
|
|
85
|
+
(spec) => spec.name,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Format the public action list for validation messages and docs. */
|
|
90
|
+
export function formatTreeSitterActionList(): string {
|
|
91
|
+
return TREE_SITTER_ACTION_NAMES.join(", ");
|
|
92
|
+
}
|
package/src/tool/guidance.ts
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
3
3
|
// Note: We intentionally do NOT include cross-tool routing (e.g., "use lsp for
|
|
4
4
|
// type info") because this package can be installed standalone without supi-lsp.
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
formatTreeSitterActionList,
|
|
8
|
+
getTreeSitterActionNamesByGuidanceGroup,
|
|
9
|
+
} from "./action-specs.ts";
|
|
10
|
+
|
|
11
|
+
const jsTsStructureActions = getTreeSitterActionNamesByGuidanceGroup("js-ts-structure")
|
|
12
|
+
.map((action) => `tree_sitter.${action}(file)`)
|
|
13
|
+
.join(", ");
|
|
14
|
+
|
|
6
15
|
export const toolDescription = `Tree-sitter tool — parser-level structure and syntax queries for supported files.
|
|
7
16
|
|
|
8
|
-
Actions:
|
|
17
|
+
Actions: ${formatTreeSitterActionList()}.
|
|
9
18
|
|
|
10
|
-
Use tree_sitter for exact syntax nodes, shallow structure, parsed imports/exports, outgoing calls, or custom AST queries within one file. file is required for all actions. line and character are 1-based UTF-16 coordinates for node_at and callees. query is required for query. outline, imports, and exports are JavaScript/TypeScript-only; node_at and query work across supported grammars; callees works for many grammars. Relative paths resolve from the session working directory.`;
|
|
19
|
+
Use tree_sitter for exact syntax nodes, shallow structure, parsed imports/exports, outgoing calls, or custom AST queries within one file. file is required for all actions. line and character are 1-based UTF-16 coordinates for node_at and callees. query is required for query. outline, imports, and exports are JavaScript/TypeScript-only; node_at and query work across supported grammars; callees works for many grammars. Relative paths resolve from the session working directory, and a leading @ on file paths is stripped.`;
|
|
11
20
|
|
|
12
21
|
export const promptGuidelines = [
|
|
13
|
-
|
|
22
|
+
`Use ${jsTsStructureActions} for shallow JavaScript or TypeScript structure without reading the whole file.`,
|
|
14
23
|
"Use tree_sitter.node_at(file, line, character) for the exact syntax node and ancestry at a known position.",
|
|
15
24
|
"Use tree_sitter.callees(file, line, character) for outgoing calls from the enclosing function or method at a known position.",
|
|
16
25
|
"Use tree_sitter.query(file, query) for custom Tree-sitter patterns when the built-in actions are not specific enough.",
|
package/src/tree-sitter.ts
CHANGED
|
@@ -5,6 +5,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { detectGrammar, isJsTsGrammar } from "./language.ts";
|
|
7
7
|
import { TreeSitterRuntime } from "./session/runtime.ts";
|
|
8
|
+
import {
|
|
9
|
+
clearSessionTreeSitterService,
|
|
10
|
+
setSessionTreeSitterService,
|
|
11
|
+
} from "./session/service-registry.ts";
|
|
12
|
+
import { createTreeSitterService } from "./session/session.ts";
|
|
13
|
+
import {
|
|
14
|
+
formatTreeSitterActionList,
|
|
15
|
+
getTreeSitterActionSpec,
|
|
16
|
+
isTreeSitterAction,
|
|
17
|
+
TREE_SITTER_ACTION_NAMES,
|
|
18
|
+
type TreeSitterAction,
|
|
19
|
+
} from "./tool/action-specs.ts";
|
|
8
20
|
import {
|
|
9
21
|
formatNonSuccess,
|
|
10
22
|
formatOutlineItemsCapped,
|
|
@@ -18,26 +30,30 @@ import { promptGuidelines, promptSnippet, toolDescription } from "./tool/guidanc
|
|
|
18
30
|
import { collectOutline } from "./tool/outline.ts";
|
|
19
31
|
import { extractExports, extractImports, lookupCalleesAt, lookupNodeAt } from "./tool/structure.ts";
|
|
20
32
|
|
|
21
|
-
const TreeSitterActionEnum = StringEnum(
|
|
22
|
-
"outline",
|
|
23
|
-
"imports",
|
|
24
|
-
"exports",
|
|
25
|
-
"node_at",
|
|
26
|
-
"query",
|
|
27
|
-
"callees",
|
|
28
|
-
] as const);
|
|
33
|
+
const TreeSitterActionEnum = StringEnum(TREE_SITTER_ACTION_NAMES);
|
|
29
34
|
|
|
30
35
|
export default function treeSitterExtension(pi: ExtensionAPI) {
|
|
31
36
|
let runtime: TreeSitterRuntime | undefined;
|
|
37
|
+
let activeCwd: string | null = null;
|
|
32
38
|
|
|
33
39
|
pi.on("session_start", (_event, ctx) => {
|
|
34
|
-
runtime
|
|
40
|
+
if (runtime && activeCwd) {
|
|
41
|
+
clearSessionTreeSitterService(activeCwd);
|
|
42
|
+
runtime.dispose();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
activeCwd = ctx.cwd;
|
|
35
46
|
runtime = new TreeSitterRuntime(ctx.cwd);
|
|
47
|
+
setSessionTreeSitterService(ctx.cwd, createTreeSitterService(runtime));
|
|
36
48
|
});
|
|
37
49
|
|
|
38
50
|
pi.on("session_shutdown", () => {
|
|
51
|
+
if (activeCwd) {
|
|
52
|
+
clearSessionTreeSitterService(activeCwd);
|
|
53
|
+
}
|
|
39
54
|
runtime?.dispose();
|
|
40
55
|
runtime = undefined;
|
|
56
|
+
activeCwd = null;
|
|
41
57
|
});
|
|
42
58
|
|
|
43
59
|
pi.registerTool({
|
|
@@ -73,8 +89,6 @@ export default function treeSitterExtension(pi: ExtensionAPI) {
|
|
|
73
89
|
});
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
type TreeSitterAction = "outline" | "imports" | "exports" | "node_at" | "query" | "callees";
|
|
77
|
-
|
|
78
92
|
type ToolParams = {
|
|
79
93
|
action?: string;
|
|
80
94
|
file?: string;
|
|
@@ -83,17 +97,47 @@ type ToolParams = {
|
|
|
83
97
|
query?: string;
|
|
84
98
|
};
|
|
85
99
|
|
|
100
|
+
interface ValidatedToolParams {
|
|
101
|
+
action: TreeSitterAction;
|
|
102
|
+
file: string;
|
|
103
|
+
line?: number;
|
|
104
|
+
character?: number;
|
|
105
|
+
query?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const SUPPORTED_ACTIONS_TEXT = formatTreeSitterActionList();
|
|
109
|
+
|
|
110
|
+
const ACTION_HANDLERS: Record<
|
|
111
|
+
TreeSitterAction,
|
|
112
|
+
(runtime: TreeSitterRuntime, params: ValidatedToolParams) => Promise<string>
|
|
113
|
+
> = {
|
|
114
|
+
outline: (runtime, params) => handleOutline(runtime, params.file),
|
|
115
|
+
imports: (runtime, params) => handleImports(runtime, params.file),
|
|
116
|
+
exports: (runtime, params) => handleExports(runtime, params.file),
|
|
117
|
+
node_at: (runtime, params) =>
|
|
118
|
+
handleNodeAt(runtime, params.file, params.line as number, params.character as number),
|
|
119
|
+
query: (runtime, params) => handleQuery(runtime, params.file, params.query as string),
|
|
120
|
+
callees: (runtime, params) =>
|
|
121
|
+
handleCallees(runtime, params.file, params.line as number, params.character as number),
|
|
122
|
+
};
|
|
123
|
+
|
|
86
124
|
async function executeToolAction(runtime: TreeSitterRuntime, params: ToolParams): Promise<string> {
|
|
125
|
+
const validated = validateToolParams(params);
|
|
126
|
+
if (typeof validated === "string") {
|
|
127
|
+
return validated;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return ACTION_HANDLERS[validated.action](runtime, validated);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function validateToolParams(params: ToolParams): ValidatedToolParams | string {
|
|
87
134
|
if (!params.action) {
|
|
88
|
-
return validationError(
|
|
89
|
-
"`action` is required. Supported: outline, imports, exports, node_at, query, callees.",
|
|
90
|
-
);
|
|
135
|
+
return validationError(`\`action\` is required. Supported: ${SUPPORTED_ACTIONS_TEXT}.`);
|
|
91
136
|
}
|
|
92
137
|
|
|
93
|
-
|
|
94
|
-
if (!action) {
|
|
138
|
+
if (!isTreeSitterAction(params.action)) {
|
|
95
139
|
return validationError(
|
|
96
|
-
`Unknown action: ${params.action}. Supported:
|
|
140
|
+
`Unknown action: ${params.action}. Supported: ${SUPPORTED_ACTIONS_TEXT}`,
|
|
97
141
|
);
|
|
98
142
|
}
|
|
99
143
|
|
|
@@ -101,12 +145,37 @@ async function executeToolAction(runtime: TreeSitterRuntime, params: ToolParams)
|
|
|
101
145
|
return validationError("`file` is required for all actions.");
|
|
102
146
|
}
|
|
103
147
|
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
148
|
+
const spec = getTreeSitterActionSpec(params.action);
|
|
149
|
+
if (spec.requiresPosition) {
|
|
150
|
+
const lineError = validatePositiveInteger("line", params.line, params.action);
|
|
151
|
+
if (lineError) return lineError;
|
|
152
|
+
|
|
153
|
+
const characterError = validatePositiveInteger("character", params.character, params.action);
|
|
154
|
+
if (characterError) return characterError;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (spec.requiresQuery && (!params.query || params.query.trim().length === 0)) {
|
|
158
|
+
return validationError("`query` is required and must be non-empty.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
action: params.action,
|
|
163
|
+
file: params.file,
|
|
164
|
+
line: params.line,
|
|
165
|
+
character: params.character,
|
|
166
|
+
query: params.query,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validatePositiveInteger(
|
|
171
|
+
field: "line" | "character",
|
|
172
|
+
value: number | undefined,
|
|
173
|
+
action: TreeSitterAction,
|
|
174
|
+
): string | null {
|
|
175
|
+
if (value === undefined || !Number.isInteger(value) || value < 1) {
|
|
176
|
+
return validationError(`\`${field}\` must be a positive 1-based integer for ${action} action.`);
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
110
179
|
}
|
|
111
180
|
|
|
112
181
|
async function handleOutline(runtime: TreeSitterRuntime, file: string): Promise<string> {
|
|
@@ -175,18 +244,13 @@ async function handleExports(runtime: TreeSitterRuntime, file: string): Promise<
|
|
|
175
244
|
return lines.join("\n");
|
|
176
245
|
}
|
|
177
246
|
|
|
178
|
-
async function handleNodeAt(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const file = params.file;
|
|
187
|
-
const line = params.line as number;
|
|
188
|
-
const character = params.character as number;
|
|
189
|
-
const result = await lookupNodeAt(runtime, file as string, line, character);
|
|
247
|
+
async function handleNodeAt(
|
|
248
|
+
runtime: TreeSitterRuntime,
|
|
249
|
+
file: string,
|
|
250
|
+
line: number,
|
|
251
|
+
character: number,
|
|
252
|
+
): Promise<string> {
|
|
253
|
+
const result = await lookupNodeAt(runtime, file, line, character);
|
|
190
254
|
if (result.kind !== "success") return formatNonSuccess(result);
|
|
191
255
|
|
|
192
256
|
const { data } = result;
|
|
@@ -212,13 +276,12 @@ async function handleNodeAt(runtime: TreeSitterRuntime, params: ToolParams): Pro
|
|
|
212
276
|
return lines.join("\n");
|
|
213
277
|
}
|
|
214
278
|
|
|
215
|
-
async function handleQuery(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
const result = await runtime.queryFile(file, params.query);
|
|
279
|
+
async function handleQuery(
|
|
280
|
+
runtime: TreeSitterRuntime,
|
|
281
|
+
file: string,
|
|
282
|
+
query: string,
|
|
283
|
+
): Promise<string> {
|
|
284
|
+
const result = await runtime.queryFile(file, query);
|
|
222
285
|
if (result.kind !== "success") return formatNonSuccess(result);
|
|
223
286
|
|
|
224
287
|
const { data: captures } = result;
|
|
@@ -237,25 +300,12 @@ async function handleQuery(runtime: TreeSitterRuntime, params: ToolParams): Prom
|
|
|
237
300
|
return lines.join("\n");
|
|
238
301
|
}
|
|
239
302
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
async function handleCallees(runtime: TreeSitterRuntime, params: ToolParams): Promise<string> {
|
|
248
|
-
if (!Number.isInteger(params.line) || (params.line as number) < 1) {
|
|
249
|
-
return validationError("`line` must be a positive 1-based integer for callees action.");
|
|
250
|
-
}
|
|
251
|
-
if (!Number.isInteger(params.character) || (params.character as number) < 1) {
|
|
252
|
-
return validationError("`character` must be a positive 1-based integer for callees action.");
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const file = params.file as string;
|
|
256
|
-
const line = params.line as number;
|
|
257
|
-
const character = params.character as number;
|
|
258
|
-
|
|
303
|
+
async function handleCallees(
|
|
304
|
+
runtime: TreeSitterRuntime,
|
|
305
|
+
file: string,
|
|
306
|
+
line: number,
|
|
307
|
+
character: number,
|
|
308
|
+
): Promise<string> {
|
|
259
309
|
const result = await lookupCalleesAt(runtime, file, line, character);
|
|
260
310
|
if (result.kind !== "success") return formatNonSuccess(result);
|
|
261
311
|
|
package/src/types.ts
CHANGED
|
@@ -66,8 +66,8 @@ export interface QueryCapture {
|
|
|
66
66
|
text: string;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
export interface
|
|
69
|
+
/** Shared Tree-sitter service surface, independent of lifecycle ownership. */
|
|
70
|
+
export interface TreeSitterService {
|
|
71
71
|
/** Validate that a supported file can be read and parsed; does not expose the raw tree. */
|
|
72
72
|
canParse(file: string): Promise<TreeSitterResult<{ file: string; language: string }>>;
|
|
73
73
|
/** Run a Tree-sitter query and return all captures. */
|
|
@@ -86,10 +86,21 @@ export interface TreeSitterSession {
|
|
|
86
86
|
line: number,
|
|
87
87
|
character: number,
|
|
88
88
|
): Promise<TreeSitterResult<CalleesAtResult>>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Owned Tree-sitter session that must release its runtime resources. */
|
|
92
|
+
export interface TreeSitterSession extends TreeSitterService {
|
|
89
93
|
/** Release parser and grammar resources owned by this session. */
|
|
90
94
|
dispose(): void;
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
/** Session-scoped shared structural service published by the extension runtime. */
|
|
98
|
+
export type SessionTreeSitterService = TreeSitterService;
|
|
99
|
+
|
|
100
|
+
export type SessionTreeSitterServiceState =
|
|
101
|
+
| { kind: "ready"; service: SessionTreeSitterService }
|
|
102
|
+
| { kind: "unavailable"; reason: string };
|
|
103
|
+
|
|
93
104
|
/** Supported grammar identifiers. */
|
|
94
105
|
export type GrammarId =
|
|
95
106
|
| "javascript"
|