|
|
@@ -0,0 +1,234 @@
|
|
|
+import { ContainerModule } from '@theia/core/shared/inversify';
|
|
|
+import { inject, injectable } from '@theia/core/shared/inversify';
|
|
|
+import {
|
|
|
+ Command, CommandContribution, CommandRegistry, MenuContribution,
|
|
|
+ MenuModelRegistry, MessageService,
|
|
|
+} from '@theia/core/lib/common';
|
|
|
+import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
|
|
|
+import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
|
|
+import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
|
+import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
|
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
|
|
|
+import URI from '@theia/core/lib/common/uri';
|
|
|
+import {
|
|
|
+ ProjectTemplate, TEMPLATES, declaredSourceDirs, isValidModuleName,
|
|
|
+ isValidProjectName, moduleNameOf, renderDefModule, renderImplModule,
|
|
|
+ scaffoldFiles, validateProjectFile,
|
|
|
+} from './modula2-project';
|
|
|
+
|
|
|
+const CATEGORY = 'Modula-2';
|
|
|
+
|
|
|
+const Commands = {
|
|
|
+ newModule: Command.toLocalizedCommand({ id: 'modula2.newModule', label: 'New Module...', category: CATEGORY }),
|
|
|
+ newDefModule: Command.toLocalizedCommand({ id: 'modula2.newDefModule', label: 'New Definition Module...', category: CATEGORY }),
|
|
|
+ newProject: Command.toLocalizedCommand({ id: 'modula2.newProject', label: 'New Modula-2 Project...', category: CATEGORY }),
|
|
|
+ validate: Command.toLocalizedCommand({ id: 'modula2.validateProject', label: 'Validate Project', category: CATEGORY }),
|
|
|
+};
|
|
|
+
|
|
|
+@injectable()
|
|
|
+class ProjectCommands implements CommandContribution {
|
|
|
+ @inject(FileService) protected readonly files: FileService;
|
|
|
+ @inject(WorkspaceService) protected readonly workspace: WorkspaceService;
|
|
|
+ @inject(EditorManager) protected readonly editors: EditorManager;
|
|
|
+ @inject(MessageService) protected readonly messages: MessageService;
|
|
|
+ @inject(QuickInputService) protected readonly quickInput: QuickInputService;
|
|
|
+
|
|
|
+ registerCommands(commands: CommandRegistry): void {
|
|
|
+ commands.registerCommand(Commands.newModule, { execute: () => this.newModule(false) });
|
|
|
+ commands.registerCommand(Commands.newDefModule, { execute: () => this.newModule(true) });
|
|
|
+ commands.registerCommand(Commands.newProject, { execute: () => this.newProject() });
|
|
|
+ commands.registerCommand(Commands.validate, { execute: () => this.validateProject() });
|
|
|
+ }
|
|
|
+
|
|
|
+ protected async root(): Promise<URI | undefined> {
|
|
|
+ const roots = await this.workspace.roots;
|
|
|
+ if (roots.length === 0) {
|
|
|
+ this.messages.warn('Modula-2: open a folder first (File > Open Folder).');
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return roots[0].resource;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Candidate source directories: declared ones that exist, then src/, then the root. */
|
|
|
+ protected async sourceDirChoices(root: URI): Promise<string[]> {
|
|
|
+ const declared: string[] = [];
|
|
|
+ try {
|
|
|
+ const content = await this.files.read(root.resolve('modula2.json'));
|
|
|
+ declared.push(...declaredSourceDirs(content.value));
|
|
|
+ } catch {
|
|
|
+ // No project file: fall through to conventions.
|
|
|
+ }
|
|
|
+ const out: string[] = [];
|
|
|
+ for (const d of [...declared, 'src']) {
|
|
|
+ if (d === '.' || d === '') continue;
|
|
|
+ try {
|
|
|
+ if (await this.files.exists(root.resolve(d))) out.push(d);
|
|
|
+ } catch { /* ignore */ }
|
|
|
+ }
|
|
|
+ out.push('.');
|
|
|
+ return [...new Set(out)];
|
|
|
+ }
|
|
|
+
|
|
|
+ protected async askModuleName(): Promise<string | undefined> {
|
|
|
+ return this.quickInput.input({
|
|
|
+ prompt: 'Module name (a Modula-2 identifier, e.g. MyModule)',
|
|
|
+ placeHolder: 'MyModule',
|
|
|
+ validateInput: async value => isValidModuleName(value.trim()) ?? null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ protected async newModule(defOnly: boolean): Promise<void> {
|
|
|
+ const root = await this.root();
|
|
|
+ if (!root) return;
|
|
|
+ const raw = await this.askModuleName();
|
|
|
+ if (raw === undefined) return;
|
|
|
+ const name = raw.trim();
|
|
|
+ let makeDef = defOnly;
|
|
|
+ let makeMod = !defOnly;
|
|
|
+ if (!defOnly) {
|
|
|
+ const kind = await this.quickInput.showQuickPick(
|
|
|
+ [{ label: 'Implementation + definition', description: 'MyModule.mod and MyModule.def', value: 'both' },
|
|
|
+ { label: 'Implementation only', description: 'MyModule.mod', value: 'mod' }],
|
|
|
+ { placeholder: 'What to create?' },
|
|
|
+ );
|
|
|
+ if (!kind) return;
|
|
|
+ makeDef = kind.value === 'both';
|
|
|
+ makeMod = true;
|
|
|
+ }
|
|
|
+ const choices = await this.sourceDirChoices(root);
|
|
|
+ let dir = choices[0];
|
|
|
+ if (choices.length > 1) {
|
|
|
+ const picked = await this.quickInput.showQuickPick(
|
|
|
+ choices.map(c => ({ label: c === '.' ? '(project root)' : c, value: c })),
|
|
|
+ { placeholder: 'Where to create the module?' },
|
|
|
+ );
|
|
|
+ if (!picked) return;
|
|
|
+ dir = picked.value;
|
|
|
+ }
|
|
|
+ const created: string[] = [];
|
|
|
+ const write = async (file: string, content: string): Promise<boolean> => {
|
|
|
+ const uri = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`);
|
|
|
+ try {
|
|
|
+ if (await this.files.exists(uri)) {
|
|
|
+ this.messages.warn(`Modula-2: ${file} already exists, skipped.`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ await this.files.writeFile(uri, BinaryBuffer.fromString(content));
|
|
|
+ created.push(file);
|
|
|
+ return true;
|
|
|
+ } catch (error) {
|
|
|
+ this.messages.error(`Modula-2: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ if (makeMod) await write(`${name}.mod`, renderImplModule(name));
|
|
|
+ if (makeDef) await write(`${name}.def`, renderDefModule(name));
|
|
|
+ if (created.length === 0) return;
|
|
|
+ this.messages.info(`Modula-2: created ${created.join(', ')}.`);
|
|
|
+ const first = dir === '.' ? root.resolve(created[0]) : root.resolve(`${dir}/${created[0]}`);
|
|
|
+ try {
|
|
|
+ await this.editors.open(first, { mode: 'activate' });
|
|
|
+ } catch { /* created is enough */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ protected async newProject(): Promise<void> {
|
|
|
+ const root = await this.root();
|
|
|
+ if (!root) return;
|
|
|
+ const rawName = await this.quickInput.input({
|
|
|
+ prompt: 'Project name (used for the folder and modula2.json)',
|
|
|
+ placeHolder: 'HelloWorld',
|
|
|
+ validateInput: async value => isValidProjectName(value.trim()) ?? null,
|
|
|
+ });
|
|
|
+ if (rawName === undefined) return;
|
|
|
+ const name = rawName.trim();
|
|
|
+ const template = await this.quickInput.showQuickPick(
|
|
|
+ TEMPLATES.map(t => ({ label: t.label, description: t.description, value: t.id as ProjectTemplate })),
|
|
|
+ { placeholder: 'Project template' },
|
|
|
+ );
|
|
|
+ if (!template) return;
|
|
|
+ // Scaffold at the root when it is empty, else in a subfolder.
|
|
|
+ let target = root;
|
|
|
+ try {
|
|
|
+ const stat = await this.files.resolve(root);
|
|
|
+ if ((stat.children ?? []).length > 0) target = root.resolve(name);
|
|
|
+ } catch {
|
|
|
+ target = root.resolve(name);
|
|
|
+ }
|
|
|
+ const files = scaffoldFiles(name, template.value);
|
|
|
+ try {
|
|
|
+ await this.files.createFolder(target);
|
|
|
+ for (const f of files) {
|
|
|
+ const uri = target.resolve(f.path);
|
|
|
+ const slash = f.path.lastIndexOf('/');
|
|
|
+ if (slash > 0) await this.files.createFolder(uri.parent);
|
|
|
+ if (await this.files.exists(uri)) {
|
|
|
+ this.messages.warn(`Modula-2: ${f.path} already exists, skipped.`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ await this.files.writeFile(uri, BinaryBuffer.fromString(f.content));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ this.messages.error(`Modula-2: cannot scaffold project: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const where = target.toString() === root.toString() ? 'the open folder' : `the "${name}" folder`;
|
|
|
+ this.messages.info(`Modula-2: created ${template.label} "${name}" in ${where}. Open modula2.json to review, then Modula-2: Build.`);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected async validateProject(): Promise<void> {
|
|
|
+ const root = await this.root();
|
|
|
+ if (!root) return;
|
|
|
+ const uri = root.resolve('modula2.json');
|
|
|
+ let text: string;
|
|
|
+ try {
|
|
|
+ text = (await this.files.read(uri)).value;
|
|
|
+ } catch {
|
|
|
+ this.messages.warn('Modula-2: no modula2.json in the open folder. Create one with Modula-2: New Modula-2 Project.');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const problems = validateProjectFile(text);
|
|
|
+ if (problems.length > 0) {
|
|
|
+ this.messages.error(`Modula-2: invalid project.\n${problems.join('\n')}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Existence checks against the workspace.
|
|
|
+ let raw: { name: string; main: string; sourceDirectories?: string[]; definitionDirectories?: string[] };
|
|
|
+ try {
|
|
|
+ raw = JSON.parse(text);
|
|
|
+ } catch {
|
|
|
+ this.messages.error('Modula-2: invalid project.\nmodula2.json: invalid JSON.');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const missing: string[] = [];
|
|
|
+ try {
|
|
|
+ if (!await this.files.exists(root.resolve(raw.main))) missing.push(`main file "${raw.main}" does not exist`);
|
|
|
+ } catch { /* ignore */ }
|
|
|
+ for (const key of ['sourceDirectories', 'definitionDirectories'] as const) {
|
|
|
+ for (const d of raw[key] ?? []) {
|
|
|
+ try {
|
|
|
+ if (!await this.files.exists(root.resolve(d))) missing.push(`"${key}" entry "${d}" does not exist`);
|
|
|
+ } catch { /* ignore */ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (missing.length > 0) {
|
|
|
+ this.messages.error(`Modula-2: project "${raw.name}" has problems.\n${missing.join('\n')}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.messages.info(`Modula-2: project "${raw.name}" is valid (main: ${raw.main}).`);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class ProjectMenus implements MenuContribution {
|
|
|
+ registerMenus(menus: MenuModelRegistry): void {
|
|
|
+ const submenu = ['menubar', 'modula2'];
|
|
|
+ menus.registerMenuAction(submenu, { commandId: Commands.newModule.id, order: '20' });
|
|
|
+ menus.registerMenuAction(submenu, { commandId: Commands.newDefModule.id, order: '21' });
|
|
|
+ menus.registerMenuAction(submenu, { commandId: Commands.newProject.id, order: '22' });
|
|
|
+ menus.registerMenuAction(submenu, { commandId: Commands.validate.id, order: '23' });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default new ContainerModule(bind => {
|
|
|
+ bind(CommandContribution).to(ProjectCommands).inSingletonScope();
|
|
|
+ bind(MenuContribution).to(ProjectMenus).inSingletonScope();
|
|
|
+});
|