@kubohiroya/turbowarp-plus-editor-project-model 0.1.2 → 0.2.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.
@@ -0,0 +1,328 @@
1
+ // The judgement for broadcast blocks lives in project-messages, which the layout's message
2
+ // section also uses, so the section and this search agree.
3
+ import {messageReference} from './project-messages.ts';
4
+
5
+ type Field = {id?: string; value: unknown};
6
+ type Block = {
7
+ id: string;
8
+ opcode: string;
9
+ next?: string;
10
+ shadow?: boolean;
11
+ fields?: Record<string, Field>;
12
+ inputs?: Record<string, {block?: string; shadow?: string}>;
13
+ mutation?: {proccode?: string; blockInfo?: {text?: string}};
14
+ };
15
+ type Asset = {name: string};
16
+ type Comment = {text: string; blockId?: string | null};
17
+ type Variable = {name: string; type?: string; isCloud?: boolean; value?: unknown};
18
+ type Target = {
19
+ id: string;
20
+ isStage: boolean;
21
+ isOriginal: boolean;
22
+ getName(): string;
23
+ getCostumes(): Asset[];
24
+ getSounds(): Asset[];
25
+ variables: Record<string, Variable>;
26
+ comments?: Record<string, Comment>;
27
+ // The VM answers `undefined` for a missing id, including an input with no block.
28
+ blocks: {getScripts(): string[]; getBlock(id: string | undefined): Block | undefined};
29
+ };
30
+ export type SearchProject = {
31
+ targets: Target[];
32
+ getLabelForOpcode?(opcode: string): {category?: string; label?: string} | undefined;
33
+ resetAllCaches?(): void;
34
+ emitProjectChanged?(): void;
35
+ on?(event: 'PROJECT_CHANGED', listener: () => void): void;
36
+ removeListener?(event: 'PROJECT_CHANGED', listener: () => void): void;
37
+ };
38
+ type BlockKind = 'block' | 'variable' | 'list' | 'message' | 'argument' | 'extension';
39
+ type AssetKind = 'costume' | 'backdrop' | 'sound';
40
+ export type SearchKind = BlockKind | AssetKind | 'comment' | 'yaml';
41
+ export type VariableAccess = 'read' | 'write';
42
+ export type VariableSearch = {ownerId: string; variableId: string; access: VariableAccess};
43
+ export const variableAccessLabels = {
44
+ read: ['読み取り', 'Read'],
45
+ write: ['変更', 'Write']
46
+ } as const;
47
+ export type SearchEdit =
48
+ | {type: 'field'; id: string; field: string; value: string}
49
+ | {type: 'name'; id: string; owner: Target; name: string; variableType: string}
50
+ | {type: 'procedure'; name: string}
51
+ | {type: 'comment'; text: string};
52
+ export type ProjectSearchResult = {label: string; target: Target; detail?: string; edit?: SearchEdit} & (
53
+ {kind: BlockKind; block: Block; variableAccess?: VariableAccess} | {kind: AssetKind; asset: Asset} |
54
+ {kind: 'comment'; comment: Comment; commentId: string}
55
+ );
56
+ export type YamlSearchDocument = {reference: {providerId: string; documentId: string}; filename: string; source: string};
57
+ export type YamlSearchResult = {
58
+ kind: 'yaml'; document: YamlSearchDocument; label: string; detail: string;
59
+ range: {start: number; end: number; expectedText: string};
60
+ };
61
+ export type SearchResult = ProjectSearchResult | YamlSearchResult;
62
+ export const literalSearchPattern = (query: string, flags: string) =>
63
+ new RegExp(query.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
64
+
65
+ const headings: Record<Exclude<SearchKind, 'yaml'>, [string, string]> = {
66
+ block: ['ブロック', 'Blocks'],
67
+ variable: ['変数の使用箇所', 'Variable uses'],
68
+ list: ['リストの使用箇所', 'List uses'],
69
+ message: ['メッセージの送受信', 'Broadcasts'],
70
+ argument: ['ブロックの引数値', 'Block argument values'],
71
+ extension: ['機能拡張の使用箇所', 'Extension uses'],
72
+ comment: ['コメント', 'Comments'],
73
+ costume: ['コスチューム', 'Costumes'],
74
+ backdrop: ['背景', 'Backdrops'],
75
+ sound: ['音', 'Sounds']
76
+ };
77
+
78
+ function* walkBlocks(target: Target) {
79
+ const pending = target.blocks.getScripts().slice().reverse();
80
+ const seen = new Set<string>();
81
+ while (pending.length) {
82
+ const id = pending.pop()!;
83
+ if (seen.has(id)) continue;
84
+ seen.add(id);
85
+ const block = target.blocks.getBlock(id);
86
+ if (!block || block.shadow) continue;
87
+ const inputs: [string, Block][] = [];
88
+ for (const [key, input] of Object.entries(block.inputs ?? {})) {
89
+ const child = input.block && target.blocks.getBlock(input.block);
90
+ if (child) inputs.push([key, child]);
91
+ }
92
+ if (block.next) pending.push(block.next);
93
+ for (let i = inputs.length - 1; i >= 0; i--) {
94
+ if (!inputs[i][1].shadow) pending.push(inputs[i][1].id);
95
+ }
96
+ yield {block, inputs};
97
+ }
98
+ }
99
+
100
+ const stageProperties = new Set(['background #', 'backdrop #', 'backdrop name', 'volume']);
101
+ const spriteProperties = new Set(['x position', 'y position', 'direction', 'costume #', 'costume name', 'size', 'volume']);
102
+
103
+ const listAccesses = new Map<string, VariableAccess>([
104
+ ['data_listcontents', 'read'], ['data_itemoflist', 'read'], ['data_itemnumoflist', 'read'],
105
+ ['data_lengthoflist', 'read'], ['data_listcontainsitem', 'read'],
106
+ ['data_addtolist', 'write'], ['data_deleteoflist', 'write'], ['data_deletealloflist', 'write'],
107
+ ['data_insertatlist', 'write'], ['data_replaceitemoflist', 'write']
108
+ ]);
109
+
110
+ function variableAccess(target: Target, block: Block, inputs: [string, Block][],
111
+ stage: Target | undefined, sprites: ReadonlyMap<string, Target>):
112
+ {owner: Target; variableId: string; access: VariableAccess} | undefined {
113
+ if (block.opcode === 'sensing_of') {
114
+ const menu = inputs.find(([key]) => key === 'OBJECT')?.[1];
115
+ if (!menu?.shadow || menu.opcode !== 'sensing_of_object_menu') return;
116
+ const object = menu.fields?.OBJECT?.value;
117
+ const name = block.fields?.PROPERTY?.value;
118
+ if (typeof object !== 'string' || typeof name !== 'string') return;
119
+ const owner = object === '_stage_' ? stage : sprites.get(object);
120
+ if (!owner || (owner.isStage ? stageProperties : spriteProperties).has(name)) return;
121
+ const variableId = Object.keys(owner.variables).find(id => {
122
+ const variable = owner.variables[id];
123
+ return variable.type === '' && variable.name === name;
124
+ });
125
+ if (variableId) return {owner, variableId, access: 'read'};
126
+ return;
127
+ }
128
+ const listAccess = listAccesses.get(block.opcode);
129
+ const access = listAccess ?? (block.opcode === 'data_variable' ? 'read' :
130
+ block.opcode === 'data_setvariableto' || block.opcode === 'data_changevariableby' ? 'write' : undefined);
131
+ const variableId = block.fields?.[listAccess ? 'LIST' : 'VARIABLE']?.id;
132
+ if (!access || !variableId) return;
133
+ const owner = Object.hasOwn(target.variables, variableId) ? target : stage;
134
+ if (owner?.variables[variableId]?.type === (listAccess ? 'list' : '')) return {owner, variableId, access};
135
+ }
136
+
137
+ const originalSprites = (project: SearchProject) =>
138
+ new Map(project.targets.filter(target => target.isOriginal && !target.isStage).map(target => [target.getName(), target]));
139
+
140
+ export function countVariableAccesses(project: SearchProject, ownerId: string) {
141
+ const stage = project.targets.find(target => target.isStage);
142
+ const sprites = originalSprites(project);
143
+ const counts = new Map<string, Record<VariableAccess, number>>();
144
+ for (const target of project.targets) {
145
+ if (!target.isOriginal) continue;
146
+ for (const {block, inputs} of walkBlocks(target)) {
147
+ const use = variableAccess(target, block, inputs, stage, sprites);
148
+ if (use?.owner.id !== ownerId) continue;
149
+ const count = counts.get(use.variableId) ?? {read: 0, write: 0};
150
+ count[use.access]++;
151
+ counts.set(use.variableId, count);
152
+ }
153
+ }
154
+ return counts;
155
+ }
156
+
157
+ export function searchProject(project: SearchProject, messages: Record<string, string>, query: string, japanese: boolean,
158
+ kinds: ReadonlySet<SearchKind> = new Set(Object.keys(headings) as SearchKind[]), variableSearch?: VariableSearch) {
159
+ const groups = new Map(Object.entries(headings).map(([kind, titles]) => [
160
+ kind as SearchKind, {kind: kind as SearchKind, title: titles[japanese ? 0 : 1], results: [] as ProjectSearchResult[]}
161
+ ]));
162
+ const search = query.trim().toLocaleLowerCase();
163
+ if (!search && !variableSearch) return [];
164
+
165
+ const stage = project.targets.find(target => target.isStage);
166
+ const sprites = variableSearch || kinds.has('variable') ? originalSprites(project) : undefined;
167
+ const searchBlocks = variableSearch || (['block', 'variable', 'list', 'message', 'argument', 'extension'] as const)
168
+ .some(kind => kinds.has(kind));
169
+ const templates = new Map<string, {text: string; extension?: string}>();
170
+ const matches = (value: string) => value.toLocaleLowerCase().includes(search);
171
+ const describeOpcode = (opcode: string) => {
172
+ const cached = templates.get(opcode);
173
+ if (cached) return cached;
174
+ const key = opcode.toUpperCase();
175
+ const alternate = key.replace(/^OPERATOR_/, 'OPERATORS_').replace(/_AS_CLONE$/, 'ASCLONE');
176
+ const vmLabel = project.getLabelForOpcode?.(opcode);
177
+ const vmText = vmLabel?.label;
178
+ const separator = vmLabel?.category === 'extension' ? vmText?.indexOf(': ') ?? -1 : -1;
179
+ const template = {
180
+ text: messages[key] ?? messages[alternate] ??
181
+ (vmText && separator > 0 ? vmText.slice(separator + 2) : vmText) ?? opcode.replaceAll('_', ' '),
182
+ extension: vmText && separator > 0 ? vmText.slice(0, separator) : undefined
183
+ };
184
+ templates.set(opcode, template);
185
+ return template;
186
+ };
187
+ const fieldText = (target: Target, key: string, field: Field) => {
188
+ const variable = field.id && (key === 'VARIABLE' || key === 'LIST') ?
189
+ target.variables[field.id] ?? stage?.variables[field.id] : undefined;
190
+ return String(variable?.name ?? field.value ?? '');
191
+ };
192
+ const describe = (target: Target, block: Block, inputs: [string, Block][], template: string) => {
193
+ const values = [
194
+ ...Object.entries(block.fields ?? {}).map(([key, field]) => fieldText(target, key, field)),
195
+ ...inputs.filter(([key]) => !key.startsWith('SUBSTACK')).map(([, child]) =>
196
+ child.shadow ? Object.values(child.fields ?? {}).map(field => String(field.value ?? '')).join(' ') :
197
+ Object.entries(child.fields ?? {}).map(([key, field]) => fieldText(target, key, field)).join(' ') || '…')
198
+ ];
199
+ if (block.opcode === 'event_whenflagclicked') values.unshift(japanese ? '緑の旗' : 'green flag');
200
+ if (/%\d+/.test(template)) return template.replace(/%(\d+)/g, (_, index) => values[Number(index) - 1] ?? '…').trim();
201
+ return [template, ...values].filter(Boolean).join(' ');
202
+ };
203
+
204
+ for (const target of project.targets) {
205
+ if (!target.isOriginal) continue;
206
+ for (const {block, inputs} of searchBlocks ? walkBlocks(target) : []) {
207
+ const use = sprites && variableAccess(target, block, inputs, stage, sprites);
208
+ if (variableSearch && (!use || use.owner.id !== variableSearch.ownerId ||
209
+ use.variableId !== variableSearch.variableId || use.access !== variableSearch.access)) continue;
210
+
211
+ const variable = block.fields?.VARIABLE;
212
+ const list = block.fields?.LIST;
213
+ const message = messageReference(target, block, stage);
214
+ const kind = variable ? 'variable' : list ? 'list' : 'message';
215
+ const reference = variable ?? list ?? (message && {id: message.id, value: message.fieldValue});
216
+ const name = variable || list ? fieldText(target, variable ? 'VARIABLE' : 'LIST', reference!) : message?.name;
217
+ const opcode = describeOpcode(block.opcode);
218
+ const procedure = block.mutation?.proccode ??
219
+ inputs.find(([key]) => key === 'custom_block')?.[1].mutation?.proccode;
220
+ const customLabel = block.mutation?.blockInfo?.text;
221
+ const template = procedure ? procedure.replace(/%[sbn]/g, '…') :
222
+ typeof customLabel === 'string' ? customLabel : opcode.text;
223
+ const label = procedure ? template : describe(target, block, inputs, template);
224
+ const add = (kind: BlockKind, label: string, detail?: string, edit?: SearchEdit, access?: VariableAccess) =>
225
+ groups.get(kind)!.results.push({kind, target, block, label, detail, edit, variableAccess: access});
226
+ if (variableSearch && use) {
227
+ add(list ? 'list' : 'variable', label, use.owner.variables[use.variableId].name, undefined, use.access);
228
+ continue;
229
+ }
230
+ if (kinds.has('block') && (matches(template) || matches(block.opcode) ||
231
+ (block.opcode === 'event_whenflagclicked' && matches(japanese ? '緑の旗' : 'green flag')))) {
232
+ add('block', label, undefined, procedure ? {type: 'procedure', name: procedure} : undefined);
233
+ }
234
+ if (kinds.has(kind) && name && matches(name)) {
235
+ const referenceId = reference?.id;
236
+ const owner = referenceId &&
237
+ (target.variables[referenceId] ? target : stage?.variables[referenceId] ? stage : undefined);
238
+ const scope = (variable || list) && owner &&
239
+ (owner.isStage ? (japanese ? '全体' : 'Global') : (japanese ? 'このスプライト' : 'Local'));
240
+ const model = owner && referenceId ? owner.variables[referenceId] : undefined;
241
+ const edit = owner && referenceId && model && !model.isCloud ? {
242
+ type: 'name' as const, id: referenceId, owner, name: model.name,
243
+ variableType: model.type ?? (list ? 'list' : message ? 'broadcast_msg' : '')
244
+ } : undefined;
245
+ add(kind, name, scope || label, edit, use?.access);
246
+ } else if (use && !variable && !list && kinds.has('variable') && matches(use.owner.variables[use.variableId].name)) {
247
+ add('variable', use.owner.variables[use.variableId].name, label, undefined, use.access);
248
+ }
249
+ if (kinds.has('extension') && opcode.extension && matches(opcode.extension)) add('extension', opcode.extension, label);
250
+ if (kinds.has('argument')) {
251
+ const addArguments = (owner: Block) => {
252
+ for (const [key, field] of Object.entries(owner.fields ?? {})) {
253
+ if (field === reference || (message && key === 'BROADCAST_OPTION')) continue;
254
+ const value = String(field.value ?? '');
255
+ if (value && matches(value)) add('argument', value, label,
256
+ {type: 'field', id: owner.id, field: key, value});
257
+ }
258
+ };
259
+ addArguments(block);
260
+ for (const [, child] of inputs) {
261
+ if (child.shadow) addArguments(child);
262
+ }
263
+ }
264
+ }
265
+ if (variableSearch) continue;
266
+
267
+ if (kinds.has('comment')) {
268
+ for (const [commentId, comment] of Object.entries(target.comments ?? {})) {
269
+ if (matches(comment.text)) groups.get('comment')!.results.push({
270
+ kind: 'comment', target, comment, commentId, label: comment.text, edit: {type: 'comment', text: comment.text}
271
+ });
272
+ }
273
+ }
274
+
275
+ const assets: [AssetKind, Asset[]][] = [
276
+ [target.isStage ? 'backdrop' : 'costume', target.getCostumes()],
277
+ ['sound', target.getSounds()]
278
+ ];
279
+ for (const [kind, items] of assets) {
280
+ if (!kinds.has(kind)) continue;
281
+ for (const asset of items) {
282
+ if (matches(asset.name)) groups.get(kind)!.results.push({kind, asset, label: asset.name, target});
283
+ }
284
+ }
285
+ }
286
+ return [...groups.values()].filter(group => group.results.length);
287
+ }
288
+
289
+ export function searchYamlDocuments(documents: readonly YamlSearchDocument[], query: string, japanese: boolean) {
290
+ const results: YamlSearchResult[] = [];
291
+ const search = query.trim();
292
+ if (!search) return results;
293
+ const pattern = literalSearchPattern(search, 'giu');
294
+ const seen = new Set<string>();
295
+ const duplicates = new Set<string>();
296
+ for (const {filename} of documents) {
297
+ if (seen.has(filename)) duplicates.add(filename);
298
+ seen.add(filename);
299
+ }
300
+ for (const document of documents) {
301
+ let line = 1;
302
+ let lineStart = 0;
303
+ let newline = document.source.indexOf('\n');
304
+ for (const match of document.source.matchAll(pattern)) {
305
+ const start = match.index;
306
+ const end = start + match[0].length;
307
+ while (newline >= 0 && newline < start) {
308
+ line++;
309
+ lineStart = newline + 1;
310
+ newline = document.source.indexOf('\n', lineStart);
311
+ }
312
+ const column = start - lineStart + 1;
313
+ const nextNewline = document.source.indexOf('\n', end);
314
+ const before = Math.max(lineStart, start - 48);
315
+ const lineEnd = nextNewline < 0 ? document.source.length : nextNewline;
316
+ const after = Math.min(lineEnd, end + 48);
317
+ const position = japanese ? `${line} 行 ${column} 列` : `Line ${line}, column ${column}`;
318
+ const identity = duplicates.has(document.filename) ?
319
+ `${document.reference.providerId}/${document.reference.documentId}` : '';
320
+ results.push({
321
+ kind: 'yaml', document, label: `${before > lineStart ? '…' : ''}${document.source.slice(before, after)}${after < lineEnd ? '…' : ''}`,
322
+ detail: [position, identity].filter(Boolean).join(' · '),
323
+ range: {start, end, expectedText: match[0]}
324
+ });
325
+ }
326
+ }
327
+ return results;
328
+ }