Forráskód Böngészése

Phase 3 (1/5): GNU Modula-2 compiler service

New native modula2-compiler extension (common/browser/node split):
- common/compiler.ts: Modula2Compiler RPC contract (detect, build,
  clean, run), BuildResult/diagnostics types, modula2.json parsing
- node: GnuModula2CompilerService spawns gm2 asynchronously and
  parses GCC-style file:line:col diagnostics; backend DI module
- browser: Modula-2 Build/Build File/Clean/Rebuild/Run/Detect
  commands driven by the workspace modula2.json via the RPC proxy

Wiring: extension added to both apps and the root build chain;
workbench placeholder Build command removed (compiler owns it);
root prepare script fixed (it ran in workspaces without one).

Verified: extension compiles; service tested headlessly against
real gm2 (detect/build/run/clean/diagnostics incl. broken-file
errors); browser app bundles with the extension on both sides and
boots (HTTP 200, backend listening).
Eric Streit 2 hete
szülő
commit
e5d818c804

+ 2 - 0
README.md

@@ -14,6 +14,8 @@ A starting point for a dedicated GNU Modula-2 IDE built on Eclipse Theia.
 - Keyword-only automatic uppercasing
 - AST-backed document outline, completion, hover and go-to-definition
   (lenient declaration parser with cross-file `.def` resolution)
+- GNU Modula-2 compiler service: async Build/Build File/Clean/Rebuild/Run
+  plus compiler detection, driven by the workspace `modula2.json`
 - Initial Modula-2 commands/menu
 - Project/example/grammar placeholders
 

+ 1 - 0
apps/browser/package.json

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

+ 1 - 0
apps/electron/package.json

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

+ 14 - 1
extensions/modula2-compiler/README.md

@@ -1,3 +1,16 @@
 # modula2-compiler
 
-Reserved for GNU Modula-2 compiler detection, build, clean, run and diagnostics.
+Native Theia extension: GNU Modula-2 compiler service.
+
+- `src/common/compiler.ts` — `Modula2Compiler` interface, `modula2.json`
+  parsing, build/diagnostic types (shared frontend/backend contract).
+- `src/node/` — `GnuModula2CompilerService`: async `gm2` builds,
+  `detect`, `clean`, `run`, GCC-style diagnostics parsing.
+- `src/browser/` — `Modula-2: Build/Build File/Clean/Rebuild/Run/
+  Detect GNU Modula-2` commands driven by the workspace `modula2.json`.
+
+Test the service headlessly (needs `gm2` on PATH):
+
+```bash
+node /tmp/opencode/gm2t/check-svc.js   # see GNU-grammar session notes
+```

+ 19 - 0
extensions/modula2-compiler/package.json

@@ -0,0 +1,19 @@
+{
+  "name": "modula2-compiler",
+  "displayName": "Modula-2 Compiler 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-compiler-frontend-module"},
+    {"backend": "lib/node/modula2-compiler-backend-module"}
+  ]
+}

+ 135 - 0
extensions/modula2-compiler/src/browser/modula2-compiler-frontend-module.ts

@@ -0,0 +1,135 @@
+import { ContainerModule } from '@theia/core/shared/inversify';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import {
+  Command, CommandContribution, CommandRegistry, MessageService,
+} from '@theia/core/lib/common';
+import { RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider';
+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 {
+  BuildResult, Modula2Compiler, Modula2CompilerServicePath,
+  Modula2Project, parseProjectFile,
+} from '../common/compiler';
+
+const CATEGORY = 'Modula-2';
+
+const Commands = {
+  build: Command.toLocalizedCommand({ id: 'modula2.build', label: 'Build', category: CATEGORY }),
+  buildFile: Command.toLocalizedCommand({ id: 'modula2.buildFile', label: 'Build File', category: CATEGORY }),
+  clean: Command.toLocalizedCommand({ id: 'modula2.clean', label: 'Clean', category: CATEGORY }),
+  rebuild: Command.toLocalizedCommand({ id: 'modula2.rebuild', label: 'Rebuild', category: CATEGORY }),
+  run: Command.toLocalizedCommand({ id: 'modula2.run', label: 'Run', category: CATEGORY }),
+  detect: Command.toLocalizedCommand({ id: 'modula2.detectCompiler', label: 'Detect GNU Modula-2', category: CATEGORY }),
+};
+
+@injectable()
+class CompilerCommands implements CommandContribution {
+  @inject(Modula2Compiler) protected readonly compiler: Modula2Compiler;
+  @inject(FileService) protected readonly files: FileService;
+  @inject(WorkspaceService) protected readonly workspace: WorkspaceService;
+  @inject(EditorManager) protected readonly editors: EditorManager;
+  @inject(MessageService) protected readonly messages: MessageService;
+
+  registerCommands(commands: CommandRegistry): void {
+    commands.registerCommand(Commands.detect, { execute: () => this.detect() });
+    commands.registerCommand(Commands.build, { execute: () => this.withProject(p => this.build(p)) });
+    commands.registerCommand(Commands.buildFile, { execute: () => this.buildFile() });
+    commands.registerCommand(Commands.clean, { execute: () => this.withProject(p => this.clean(p)) });
+    commands.registerCommand(Commands.rebuild, {
+      execute: () => this.withProject(async p => { await this.compiler.clean(p); await this.build(p); }),
+    });
+    commands.registerCommand(Commands.run, { execute: () => this.withProject(p => this.run(p)) });
+  }
+
+  /** Load modula2.json from the first workspace root. */
+  protected async loadProject(): Promise<Modula2Project | undefined> {
+    const roots = await this.workspace.roots;
+    if (roots.length === 0) {
+      this.messages.warn('Modula-2: open a folder with a modula2.json project first.');
+      return undefined;
+    }
+    const rootUri = roots[0].resource;
+    try {
+      const content = await this.files.read(rootUri.resolve('modula2.json'));
+      return parseProjectFile(rootUri.path.fsPath(), content.value);
+    } catch {
+      this.messages.warn('Modula-2: no readable modula2.json in the workspace root.');
+      return undefined;
+    }
+  }
+
+  protected async withProject(op: (project: Modula2Project) => Promise<void>): Promise<void> {
+    const project = await this.loadProject();
+    if (project) {
+      try {
+        await op(project);
+      } catch (error) {
+        this.messages.error(`Modula-2: ${error instanceof Error ? error.message : String(error)}`);
+      }
+    }
+  }
+
+  protected summarize(result: BuildResult): string {
+    const errors = result.diagnostics.filter(d => d.severity === 'error').length;
+    const warnings = result.diagnostics.filter(d => d.severity === 'warning').length;
+    return `${errors} error(s), ${warnings} warning(s). Command: ${result.command}`;
+  }
+
+  protected async build(project: Modula2Project): Promise<void> {
+    const result = await this.compiler.build(project);
+    if (result.success) {
+      this.messages.info(`Modula-2: build succeeded. ${this.summarize(result)}`);
+    } else {
+      const first = result.diagnostics.slice(0, 3)
+        .map(d => `${d.file}:${d.line + 1}:${d.character + 1}: ${d.message}`).join('\n');
+      this.messages.error(`Modula-2: build failed. ${this.summarize(result)}${first ? '\n' + first : ''}`);
+    }
+  }
+
+  /** Build the Modula-2 file in the active editor instead of the project main. */
+  protected async buildFile(): Promise<void> {
+    const uri = this.editors.currentEditor?.getResourceUri();
+    if (!uri || !(uri.path.ext === '.mod' || uri.path.ext === '.def')) {
+      this.messages.warn('Modula-2: open a .mod or .def file to build it.');
+      return;
+    }
+    const project = await this.loadProject();
+    if (!project) return;
+    const root = (await this.workspace.roots)[0].resource;
+    const relative = root.relative(uri)?.toString() ?? uri.path.toString();
+    await this.build({ ...project, main: relative });
+  }
+
+  protected async clean(project: Modula2Project): Promise<void> {
+    await this.compiler.clean(project);
+    this.messages.info('Modula-2: cleaned build outputs.');
+  }
+
+  protected async run(project: Modula2Project): Promise<void> {
+    const result = await this.compiler.run(project);
+    if (!result.success && result.diagnostics.length > 0) {
+      await this.build(project);
+      return;
+    }
+    const output = result.output.length > 2000 ? result.output.slice(0, 2000) + '\n…' : result.output;
+    this.messages.info(`Modula-2: program exited with code ${result.exitCode}.\n${output}`);
+  }
+
+  protected async detect(): Promise<void> {
+    const info = await this.compiler.detect('gm2');
+    if (info) {
+      this.messages.info(`GNU Modula-2 detected: ${info.version} (${info.path})`);
+    } else {
+      this.messages.warn('Modula-2: no working gm2 executable found on PATH.');
+    }
+  }
+}
+
+export default new ContainerModule(bind => {
+  bind(Modula2Compiler).toDynamicValue(ctx => {
+    const provider = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
+    return provider.createProxy<Modula2Compiler>(Modula2CompilerServicePath);
+  }).inSingletonScope();
+  bind(CommandContribution).to(CompilerCommands).inSingletonScope();
+});

+ 99 - 0
extensions/modula2-compiler/src/common/compiler.ts

@@ -0,0 +1,99 @@
+/** Shared contract for the Modula-2 compiler service (tutorial section 27).
+ *
+ *  The frontend never spawns the compiler directly: it calls this service
+ *  over RPC and the backend runs `gm2` asynchronously (tutorial section 43).
+ *  Other compilers (XDS, TopSpeed, ...) can implement the same interface
+ *  without changing the rest of the IDE (tutorial section 28).
+ */
+
+export const Modula2CompilerServicePath = '/services/modula2-compiler';
+export const Modula2Compiler = Symbol('Modula2Compiler');
+
+export type BuildSeverity = 'error' | 'warning' | 'info';
+
+export interface BuildDiagnostic {
+  /** File path as reported by the compiler (relative to the project root). */
+  file: string;
+  /** Zero-based line and character. */
+  line: number;
+  character: number;
+  message: string;
+  severity: BuildSeverity;
+}
+
+export interface BuildResult {
+  success: boolean;
+  exitCode: number;
+  /** Exact command line that was executed (for transparency). */
+  command: string;
+  /** Combined stdout/stderr of the compiler. */
+  output: string;
+  diagnostics: BuildDiagnostic[];
+}
+
+export interface CompilerOptions {
+  /** Compiler executable, e.g. "gm2" or an absolute path. */
+  path: string;
+  options: string[];
+}
+
+export interface Modula2Project {
+  name: string;
+  /** Absolute workspace folder containing modula2.json. */
+  root: string;
+  /** Main file, relative to root (e.g. "src/Main.mod"). */
+  main: string;
+  /** Built executable, relative to root. Defaults to bin/<name>. */
+  output?: string;
+  compiler: CompilerOptions;
+}
+
+export interface CompilerInfo {
+  path: string;
+  /** First line of `gm2 --version`. */
+  version: string;
+}
+
+export interface Modula2Compiler {
+  /** Probe a compiler executable; undefined when it cannot be run. */
+  detect(compilerPath?: string): Promise<CompilerInfo | undefined>;
+  /** Compile (and link) the project main file. */
+  build(project: Modula2Project): Promise<BuildResult>;
+  /** Remove build outputs (best-effort). */
+  clean(project: Modula2Project): Promise<void>;
+  /** Build, then execute the produced program and capture its output. */
+  run(project: Modula2Project): Promise<BuildResult>;
+}
+
+/** Minimal `modula2.json` reader (tutorial sections 23/33). Pure and testable. */
+export function parseProjectFile(root: string, jsonText: string): Modula2Project {
+  const raw = JSON.parse(jsonText) as {
+    name?: unknown; main?: unknown; output?: unknown;
+    compiler?: { type?: unknown; path?: unknown; options?: unknown };
+  };
+  if (typeof raw.name !== 'string' || raw.name.length === 0) {
+    throw new Error('modula2.json: missing "name".');
+  }
+  if (typeof raw.main !== 'string' || raw.main.length === 0) {
+    throw new Error('modula2.json: missing "main".');
+  }
+  const compiler = raw.compiler ?? {};
+  const options = Array.isArray(compiler.options)
+    ? compiler.options.filter((o): o is string => typeof o === 'string')
+    : [];
+  return {
+    name: raw.name,
+    root,
+    main: raw.main,
+    output: typeof raw.output === 'string' ? raw.output : undefined,
+    compiler: {
+      path: typeof compiler.path === 'string' && compiler.path.length > 0 ? compiler.path : 'gm2',
+      options,
+    },
+  };
+}
+
+/** Default executable path for a project (bin/<name> under root). */
+export function defaultOutput(project: Modula2Project): string {
+  return project.output ?? `bin/${project.name}`;
+}

+ 134 - 0
extensions/modula2-compiler/src/node/compiler-service.ts

@@ -0,0 +1,134 @@
+/** GNU Modula-2 implementation of the compiler service.
+ *
+ *  Runs `gm2` as a child process (never blocking the frontend) and turns
+ *  its GCC-style `file:line:col: severity: message` output into structured
+ *  diagnostics. `parseGm2Diagnostics` is exported pure for testing.
+ */
+
+import { injectable } from '@theia/core/shared/inversify';
+import { spawn } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+  BuildDiagnostic, BuildResult, CompilerInfo, Modula2Compiler,
+  Modula2Project, defaultOutput,
+} from '../common/compiler';
+
+export interface ProcessResult {
+  exitCode: number;
+  output: string;
+}
+
+export function runProcess(command: string, args: string[], cwd: string): Promise<ProcessResult> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(command, args, { cwd, shell: false });
+    let output = '';
+    child.stdout.on('data', d => { output += d.toString(); });
+    child.stderr.on('data', d => { output += d.toString(); });
+    child.on('error', reject);
+    child.on('close', code => resolve({ exitCode: code ?? 1, output }));
+  });
+}
+
+const GM2_LINE = /^(.+?):(\d+):(\d+):\s+(warning|error|note|fatal error):\s+(.*)$/;
+
+/** Parse GCC-style diagnostics; context/caret lines are ignored. */
+export function parseGm2Diagnostics(output: string): BuildDiagnostic[] {
+  const diagnostics: BuildDiagnostic[] = [];
+  for (const line of output.split('\n')) {
+    const m = GM2_LINE.exec(line);
+    if (!m) continue;
+    const severity = m[4].includes('error') ? 'error' : m[4] === 'warning' ? 'warning' : 'info';
+    diagnostics.push({
+      file: m[1],
+      line: Math.max(0, Number(m[2]) - 1),
+      character: Math.max(0, Number(m[3]) - 1),
+      message: m[5].trim(),
+      severity,
+    });
+  }
+  return diagnostics;
+}
+
+@injectable()
+export class GnuModula2CompilerService implements Modula2Compiler {
+  async detect(compilerPath = 'gm2'): Promise<CompilerInfo | undefined> {
+    try {
+      const result = await runProcess(compilerPath, ['--version'], process.cwd());
+      if (result.exitCode !== 0) return undefined;
+      const version = (result.output.split('\n')[0] ?? '').trim();
+      return { path: compilerPath, version };
+    } catch {
+      return undefined;
+    }
+  }
+
+  async build(project: Modula2Project): Promise<BuildResult> {
+    const out = defaultOutput(project);
+    await fs.promises.mkdir(path.dirname(path.join(project.root, out)), { recursive: true });
+    const args = ['-fiso', ...project.compiler.options, '-o', out, project.main];
+    const command = [project.compiler.path, ...args].join(' ');
+    let result: ProcessResult;
+    try {
+      result = await runProcess(project.compiler.path, args, project.root);
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      return {
+        success: false, exitCode: 127, command,
+        output: `Could not start compiler: ${message}`,
+        diagnostics: [],
+      };
+    }
+    return {
+      success: result.exitCode === 0,
+      exitCode: result.exitCode,
+      command,
+      output: result.output,
+      diagnostics: parseGm2Diagnostics(result.output),
+    };
+  }
+
+  async clean(project: Modula2Project): Promise<void> {
+    const out = path.join(project.root, defaultOutput(project));
+    await fs.promises.rm(out, { force: true });
+    // Best-effort removal of compiler side-products next to the sources.
+    const srcDir = path.dirname(path.join(project.root, project.main));
+    let entries: string[] = [];
+    try {
+      entries = await fs.promises.readdir(srcDir);
+    } catch {
+      return;
+    }
+    await Promise.all(entries
+      .filter(e => e.endsWith('.o') || e.endsWith('.s') || e.endsWith('.lst'))
+      .map(e => fs.promises.rm(path.join(srcDir, e), { force: true })));
+  }
+
+  async run(project: Modula2Project): Promise<BuildResult> {
+    const built = await this.build(project);
+    if (!built.success) return built;
+    const executable = path.join(project.root, defaultOutput(project));
+    let result: ProcessResult;
+    try {
+      result = await runProcess(executable, [], project.root);
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      return {
+        success: false, exitCode: 127, command: executable,
+        output: `Could not start program: ${message}`,
+        diagnostics: [],
+      };
+    }
+    return {
+      success: result.exitCode === 0,
+      exitCode: result.exitCode,
+      command: executable,
+      output: result.output,
+      diagnostics: result.exitCode === 0 ? [] : [{
+        file: project.main, line: 0, character: 0,
+        message: `Program exited with code ${result.exitCode}.`,
+        severity: 'error',
+      }],
+    };
+  }
+}

+ 14 - 0
extensions/modula2-compiler/src/node/modula2-compiler-backend-module.ts

@@ -0,0 +1,14 @@
+import { ContainerModule } from '@theia/core/shared/inversify';
+import { ConnectionHandler } from '@theia/core/lib/common/messaging/handler';
+import { RpcConnectionHandler } from '@theia/core/lib/common/messaging/proxy-factory';
+import { Modula2Compiler, Modula2CompilerServicePath } from '../common/compiler';
+import { GnuModula2CompilerService } from './compiler-service';
+
+export default new ContainerModule(bind => {
+  bind(Modula2Compiler).to(GnuModula2CompilerService).inSingletonScope();
+  bind(ConnectionHandler).toDynamicValue(ctx =>
+    new RpcConnectionHandler<Modula2Compiler>(
+      Modula2CompilerServicePath,
+      () => ctx.container.get<Modula2Compiler>(Modula2Compiler),
+    )).inSingletonScope();
+});

+ 10 - 0
extensions/modula2-compiler/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"]
+}

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

@@ -91,6 +91,7 @@
   "scripts": {
     "clean": "rimraf out",
     "build": "tsc -p tsconfig.json",
+    "prepare": "yarn build",
     "watch": "tsc -p tsconfig.json --watch"
   },
   "devDependencies": {

+ 0 - 5
extensions/modula2-workbench/src/browser/modula2-workbench-frontend-module.ts

@@ -5,7 +5,6 @@ const category = 'Modula-2';
 
 const Commands = {
   about: Command.toLocalizedCommand({ id: 'modula2.about', label: 'About Modula-2 IDE', category }),
-  build: Command.toLocalizedCommand({ id: 'modula2.build', label: 'Build', category })
 };
 
 class CommandsContribution implements CommandContribution {
@@ -13,9 +12,6 @@ class CommandsContribution implements CommandContribution {
     commands.registerCommand(Commands.about, {
       execute: () => window.alert('Modula-2 IDE\\n\\nGNU Modula-2 workbench for Eclipse Theia.')
     });
-    commands.registerCommand(Commands.build, {
-      execute: () => window.alert('Modula-2: Build\\n\\nCompiler service not connected yet.')
-    });
   }
 }
 
@@ -24,7 +20,6 @@ class MenusContribution implements MenuContribution {
     const submenu = ['menubar', 'modula2'];
     menus.registerSubmenu(submenu, 'Modula-2');
     menus.registerMenuAction(submenu, { commandId: Commands.about.id });
-    menus.registerMenuAction(submenu, { commandId: Commands.build.id });
   }
 }
 

+ 4 - 2
package.json

@@ -10,13 +10,15 @@
   "workspaces": [
     "apps/browser",
     "apps/electron",
+    "extensions/modula2-compiler",
     "extensions/modula2-language",
     "extensions/modula2-workbench"
   ],
   "scripts": {
-    "prepare": "yarn workspaces run prepare",
-    "build": "yarn build:language && yarn build:workbench && yarn build:browser",
+    "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",
     "build:language": "yarn --cwd extensions/modula2-language build && bash scripts/sync-language-plugin.sh",
+    "build:compiler": "yarn --cwd extensions/modula2-compiler build",
     "build:workbench": "yarn --cwd extensions/modula2-workbench build",
     "build:browser": "yarn --cwd apps/browser bundle",
     "build:electron": "yarn --cwd apps/electron bundle",

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 423 - 167
yarn.lock


Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott