Kaynağa Gözat

Project support: wizards, validation and modula2.json schema

- new modula2-project extension: New Module/Definition Module/Project
  wizards plus Validate Project, wired into apps and workspaces
- modula2.json JSON schema via the language plugin jsonValidation
- all scaffold templates verified to build with gm2
Eric Streit 1 hafta önce
ebeveyn
işleme
057b4a13e1

+ 5 - 0
SOURCE-TREE.txt

@@ -12,8 +12,13 @@ extensions/modula2-language/snippets/modula2-definition.json
 extensions/modula2-language/snippets/modula2.json
 extensions/modula2-language/snippets/modula2.json
 extensions/modula2-language/src/extension.ts
 extensions/modula2-language/src/extension.ts
 extensions/modula2-language/syntaxes/modula2.tmLanguage.json
 extensions/modula2-language/syntaxes/modula2.tmLanguage.json
+extensions/modula2-language/modula2-schema.json
 extensions/modula2-language/tsconfig.json
 extensions/modula2-language/tsconfig.json
 extensions/modula2-project/README.md
 extensions/modula2-project/README.md
+extensions/modula2-project/package.json
+extensions/modula2-project/src/browser/modula2-project-frontend-module.ts
+extensions/modula2-project/src/browser/modula2-project.ts
+extensions/modula2-project/tsconfig.json
 extensions/modula2-workbench/package.json
 extensions/modula2-workbench/package.json
 extensions/modula2-workbench/src/browser/modula2-workbench-frontend-module.ts
 extensions/modula2-workbench/src/browser/modula2-workbench-frontend-module.ts
 extensions/modula2-workbench/tsconfig.json
 extensions/modula2-workbench/tsconfig.json

+ 1 - 0
apps/browser/package.json

@@ -17,6 +17,7 @@
     "@theia/terminal": "1.75.0",
     "@theia/terminal": "1.75.0",
     "@theia/workspace": "1.75.0",
     "@theia/workspace": "1.75.0",
     "modula2-compiler": "0.2.0",
     "modula2-compiler": "0.2.0",
+    "modula2-project": "0.1.0",
     "modula2-workbench": "0.1.0",
     "modula2-workbench": "0.1.0",
     "react": "^19.0.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0"
     "react-dom": "^19.0.0"

+ 1 - 0
apps/electron/package.json

@@ -19,6 +19,7 @@
     "@theia/terminal": "1.75.0",
     "@theia/terminal": "1.75.0",
     "@theia/workspace": "1.75.0",
     "@theia/workspace": "1.75.0",
     "modula2-compiler": "0.2.0",
     "modula2-compiler": "0.2.0",
+    "modula2-project": "0.1.0",
     "modula2-workbench": "0.1.0",
     "modula2-workbench": "0.1.0",
     "react": "^19.0.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0"
     "react-dom": "^19.0.0"

+ 60 - 0
extensions/modula2-language/modula2-schema.json

@@ -0,0 +1,60 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "Modula-2 project",
+  "description": "Project description consumed by the Modula-2 IDE (build, run, tasks, wizards).",
+  "type": "object",
+  "required": ["name", "main"],
+  "properties": {
+    "name": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Project name. Defaults the built executable to bin/<name>."
+    },
+    "main": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Main file relative to the project root, e.g. \"src/Main.mod\"."
+    },
+    "output": {
+      "type": "string",
+      "description": "Built executable relative to the root. Defaults to bin/<name>."
+    },
+    "compiler": {
+      "type": "object",
+      "description": "Compiler selection. Only GNU Modula-2 (gm2) is currently implemented.",
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["gnu-modula2"],
+          "description": "Compiler family."
+        },
+        "path": {
+          "type": "string",
+          "description": "Compiler executable. Defaults to \"gm2\" (or the modula2.compiler.path preference)."
+        },
+        "options": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "Extra compiler options appended to the build command."
+        }
+      },
+      "additionalProperties": false
+    },
+    "sourceDirectories": {
+      "type": "array",
+      "items": { "type": "string" },
+      "description": "Source folders searched by the New Module wizard. Defaults to [\"src\"]."
+    },
+    "definitionDirectories": {
+      "type": "array",
+      "items": { "type": "string" },
+      "description": "Reserved: definition-module folders for future project indexing."
+    },
+    "libraryDirectories": {
+      "type": "array",
+      "items": { "type": "string" },
+      "description": "Reserved: library folders for future project indexing."
+    }
+  },
+  "additionalProperties": false
+}

+ 6 - 0
extensions/modula2-language/package.json

@@ -52,6 +52,12 @@
         "path": "./syntaxes/modula2-definition.tmLanguage.json"
         "path": "./syntaxes/modula2-definition.tmLanguage.json"
       }
       }
     ],
     ],
+    "jsonValidation": [
+      {
+        "fileMatch": "modula2.json",
+        "url": "./modula2-schema.json"
+      }
+    ],
     "snippets": [
     "snippets": [
       {
       {
         "language": "modula2",
         "language": "modula2",

+ 16 - 0
extensions/modula2-project/package.json

@@ -0,0 +1,16 @@
+{
+  "name": "modula2-project",
+  "displayName": "Modula-2 Project Support",
+  "version": "0.1.0",
+  "keywords": ["theia-extension"],
+  "files": ["lib", "src"],
+  "dependencies": {
+    "@theia/core": "1.75.0",
+    "@theia/editor": "1.75.0",
+    "@theia/filesystem": "1.75.0",
+    "@theia/workspace": "1.75.0"
+  },
+  "devDependencies": {"rimraf": "^6.0.1", "typescript": "^5.9.2"},
+  "scripts": {"clean": "rimraf lib", "build": "tsc -p tsconfig.json", "watch": "tsc -p tsconfig.json --watch", "prepare": "yarn clean && yarn build"},
+  "theiaExtensions": [{"frontend": "lib/browser/modula2-project-frontend-module"}]
+}

+ 234 - 0
extensions/modula2-project/src/browser/modula2-project-frontend-module.ts

@@ -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();
+});

+ 171 - 0
extensions/modula2-project/src/browser/modula2-project.ts

@@ -0,0 +1,171 @@
+/** Pure project logic for the Modula-2 project extension (testable headlessly).
+ *
+ *  Covers module/project name validation, file templates, structural
+ *  `modula2.json` validation and candidate source directories. All
+ *  filesystem and UI interaction lives in the frontend module.
+ */
+
+export type ProjectTemplate = 'console' | 'empty' | 'library';
+
+export const TEMPLATES: Array<{ id: ProjectTemplate; label: string; description: string }> = [
+  { id: 'console', label: 'Console application', description: 'Hello-world program with modula2.json' },
+  { id: 'empty', label: 'Empty project', description: 'modula2.json and an empty src directory' },
+  { id: 'library', label: 'Library', description: 'Definition + implementation module skeleton' },
+];
+
+/** Error message for an invalid module name, or undefined when valid. */
+export function isValidModuleName(name: string): string | undefined {
+  if (!name) return 'Module name must not be empty.';
+  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
+    return `"${name}" is not a valid Modula-2 identifier (letters, digits, underscore; must not start with a digit).`;
+  }
+  return undefined;
+}
+
+/** Error message for an invalid project/folder name, or undefined when valid. */
+export function isValidProjectName(name: string): string | undefined {
+  if (!name) return 'Project name must not be empty.';
+  if (/[/\\]/.test(name) || name === '.' || name === '..') {
+    return `"${name}" must be a plain folder name without path separators.`;
+  }
+  return undefined;
+}
+
+/** Derive a module identifier from an arbitrary project name (`my-lib` -> `MyLib`). */
+export function moduleNameOf(projectName: string): string {
+  const parts = projectName.split(/[^A-Za-z0-9]+/).filter(p => p.length > 0);
+  const camel = parts.map(p => p[0].toUpperCase() + p.slice(1)).join('');
+  const clean = camel.replace(/^[^A-Za-z_]+/, '');
+  return clean || 'Main';
+}
+
+export function renderImplModule(name: string): string {
+  return `MODULE ${name};
+
+BEGIN
+  (* TODO *)
+END ${name}.
+`;
+}
+
+export function renderDefModule(name: string): string {
+  return `DEFINITION MODULE ${name};
+
+END ${name}.
+`;
+}
+
+export function renderMain(name: string): string {
+  return `MODULE ${name};
+
+FROM InOut IMPORT WriteString, WriteLn;
+
+BEGIN
+  WriteString("Hello, Modula-2!");
+  WriteLn;
+END ${name}.
+`;
+}
+
+export function renderProjectJson(name: string, main: string): string {
+  return JSON.stringify({
+    name,
+    compiler: { type: 'gnu-modula-2', path: 'gm2', options: [] },
+    sourceDirectories: ['src'],
+    definitionDirectories: [],
+    main,
+  }, undefined, 2) + '\n';
+}
+
+export function renderReadme(name: string): string {
+  return `# ${name}\n\nA GNU Modula-2 project. Build with the Modula-2 IDE\n(\`Modula-2: Build\`) or from the terminal:\n\n\`\`\`bash\ngm2 -o bin/${name} src/Main.mod\n\`\`\`\n`;
+}
+
+/** Files (relative to the project root) produced for a template. */
+export function scaffoldFiles(projectName: string, template: ProjectTemplate): Array<{ path: string; content: string }> {
+  const mod = moduleNameOf(projectName);
+  switch (template) {
+    case 'console':
+      return [
+        { path: 'modula2.json', content: renderProjectJson(projectName, 'src/Main.mod') },
+        { path: 'src/Main.mod', content: renderMain(mod === 'Main' ? 'Main' : mod) },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+    case 'empty':
+      return [
+        { path: 'modula2.json', content: renderProjectJson(projectName, 'src/Main.mod') },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+    case 'library':
+      return [
+        { path: 'modula2.json', content: renderProjectJson(projectName, `src/${mod}.mod`) },
+        { path: `src/${mod}.def`, content: renderDefModule(mod) },
+        { path: `src/${mod}.mod`, content: renderImplModule(mod) },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+  }
+}
+
+/** Structural problems in a `modula2.json` document (no filesystem access). */
+export function validateProjectFile(jsonText: string): string[] {
+  let raw: {
+    name?: unknown; main?: unknown; output?: unknown;
+    compiler?: { path?: unknown; options?: unknown };
+    sourceDirectories?: unknown; definitionDirectories?: unknown;
+  };
+  try {
+    raw = JSON.parse(jsonText);
+  } catch {
+    return ['modula2.json: invalid JSON.'];
+  }
+  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
+    return ['modula2.json: top level must be an object.'];
+  }
+  const problems: string[] = [];
+  if (typeof raw.name !== 'string' || raw.name.length === 0) {
+    problems.push('modula2.json: missing "name" (project name).');
+  }
+  if (typeof raw.main !== 'string' || raw.main.length === 0) {
+    problems.push('modula2.json: missing "main" (main file, e.g. "src/Main.mod").');
+  } else if (!/\.(mod|def)$/.test(raw.main)) {
+    problems.push(`modula2.json: "main" should point at a .mod file (got "${raw.main}").`);
+  }
+  if (raw.output !== undefined && (typeof raw.output !== 'string' || raw.output.length === 0)) {
+    problems.push('modula2.json: "output" must be a non-empty path when present.');
+  }
+  const compiler = (raw as { compiler?: unknown }).compiler;
+  if (compiler !== undefined) {
+    if (typeof compiler !== 'object' || compiler === null) {
+      problems.push('modula2.json: "compiler" must be an object.');
+    } else {
+      const c = compiler as { path?: unknown; options?: unknown };
+      if (c.path !== undefined && (typeof c.path !== 'string' || c.path.length === 0)) {
+        problems.push('modula2.json: "compiler.path" must be a non-empty executable path.');
+      }
+      if (c.options !== undefined &&
+          (!Array.isArray(c.options) || c.options.some(o => typeof o !== 'string'))) {
+        problems.push('modula2.json: "compiler.options" must be an array of strings.');
+      }
+    }
+  }
+  for (const key of ['sourceDirectories', 'definitionDirectories'] as const) {
+    const v = (raw as Record<string, unknown>)[key];
+    if (v !== undefined && (!Array.isArray(v) || v.some(d => typeof d !== 'string'))) {
+      problems.push(`modula2.json: "${key}" must be an array of strings.`);
+    }
+  }
+  return problems;
+}
+
+/** Declared source directories from a `modula2.json` document (tolerant; empty when absent). */
+export function declaredSourceDirs(jsonText: string): string[] {
+  try {
+    const raw = JSON.parse(jsonText) as { sourceDirectories?: unknown };
+    if (Array.isArray(raw.sourceDirectories)) {
+      return raw.sourceDirectories.filter((d): d is string => typeof d === 'string' && d.length > 0);
+    }
+  } catch {
+    // Fall through: no declared directories.
+  }
+  return [];
+}

+ 10 - 0
extensions/modula2-project/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "target": "ES2022", "module": "commonjs", "lib": ["ES2022"],
+    "outDir": "lib", "rootDir": "src", "strict": true,
+    "experimentalDecorators": true, "emitDecoratorMetadata": true,
+    "strictPropertyInitialization": false,
+    "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true
+  },
+  "include": ["src/**/*.ts"]
+}

+ 4 - 2
package.json

@@ -12,13 +12,15 @@
     "apps/electron",
     "apps/electron",
     "extensions/modula2-compiler",
     "extensions/modula2-compiler",
     "extensions/modula2-language",
     "extensions/modula2-language",
+    "extensions/modula2-project",
     "extensions/modula2-workbench"
     "extensions/modula2-workbench"
   ],
   ],
   "scripts": {
   "scripts": {
-    "prepare": "yarn --cwd extensions/modula2-compiler prepare && yarn --cwd extensions/modula2-language prepare && yarn --cwd extensions/modula2-workbench prepare",
-    "build": "yarn build:language && yarn build:compiler && yarn build:workbench && yarn build:browser",
+    "prepare": "yarn --cwd extensions/modula2-compiler prepare && yarn --cwd extensions/modula2-language prepare && yarn --cwd extensions/modula2-project prepare && yarn --cwd extensions/modula2-workbench prepare",
+    "build": "yarn build:language && yarn build:compiler && yarn build:project && yarn build:workbench && yarn build:browser",
     "build:language": "yarn --cwd extensions/modula2-language build && bash scripts/sync-language-plugin.sh",
     "build:language": "yarn --cwd extensions/modula2-language build && bash scripts/sync-language-plugin.sh",
     "build:compiler": "yarn --cwd extensions/modula2-compiler build",
     "build:compiler": "yarn --cwd extensions/modula2-compiler build",
+    "build:project": "yarn --cwd extensions/modula2-project build",
     "build:workbench": "yarn --cwd extensions/modula2-workbench build",
     "build:workbench": "yarn --cwd extensions/modula2-workbench build",
     "build:browser": "yarn --cwd apps/browser bundle",
     "build:browser": "yarn --cwd apps/browser bundle",
     "build:electron": "yarn --cwd apps/electron bundle",
     "build:electron": "yarn --cwd apps/electron bundle",