Jelajahi Sumber

Pascal native extensions: compiler, debug, workbench

Eric Streit 2 hari lalu
induk
melakukan
4740734a28
29 mengubah file dengan 2269 tambahan dan 5 penghapusan
  1. 4 1
      apps/browser/package.json
  2. 8 1
      apps/electron/package.json
  3. 16 0
      extensions/pascal-compiler/README.md
  4. 39 0
      extensions/pascal-compiler/package.json
  5. 101 0
      extensions/pascal-compiler/src/browser/pascal-build-dialog.ts
  6. 244 0
      extensions/pascal-compiler/src/browser/pascal-compiler-frontend-module.ts
  7. 28 0
      extensions/pascal-compiler/src/browser/pascal-preferences.ts
  8. 19 0
      extensions/pascal-compiler/src/browser/pascal-project.ts
  9. 61 0
      extensions/pascal-compiler/src/browser/pascal-tasks.ts
  10. 143 0
      extensions/pascal-compiler/src/common/compiler.ts
  11. 176 0
      extensions/pascal-compiler/src/node/compiler-service.ts
  12. 14 0
      extensions/pascal-compiler/src/node/pascal-compiler-backend-module.ts
  13. 10 0
      extensions/pascal-compiler/tsconfig.json
  14. 17 0
      extensions/pascal-debug/README.md
  15. 14 0
      extensions/pascal-debug/package.json
  16. 672 0
      extensions/pascal-debug/src/adapter/gdb-adapter.ts
  17. 237 0
      extensions/pascal-debug/src/adapter/mi.ts
  18. 138 0
      extensions/pascal-debug/src/node/debug-adapter-contribution.ts
  19. 7 0
      extensions/pascal-debug/src/node/pascal-debug-backend-module.ts
  20. 10 0
      extensions/pascal-debug/tsconfig.json
  21. 11 0
      extensions/pascal-workbench/package.json
  22. 62 0
      extensions/pascal-workbench/src/browser/pascal-semantic-colors.ts
  23. 74 0
      extensions/pascal-workbench/src/browser/pascal-semantic-style.ts
  24. 44 0
      extensions/pascal-workbench/src/browser/pascal-theme-commands.ts
  25. 44 0
      extensions/pascal-workbench/src/browser/pascal-ui-font-contribution.ts
  26. 16 0
      extensions/pascal-workbench/src/browser/pascal-ui-preferences.ts
  27. 41 0
      extensions/pascal-workbench/src/browser/pascal-workbench-frontend-module.ts
  28. 10 0
      extensions/pascal-workbench/tsconfig.json
  29. 9 3
      package.json

+ 4 - 1
apps/browser/package.json

@@ -23,7 +23,10 @@
     "modula2-project": "0.1.0",
     "modula2-workbench": "0.1.0",
     "react": "^19.0.0",
-    "react-dom": "^19.0.0"
+    "react-dom": "^19.0.0",
+    "pascal-compiler": "0.2.0",
+    "pascal-debug": "0.1.0",
+    "pascal-workbench": "0.1.0"
   },
   "devDependencies": {
     "@theia/cli": "1.75.0",

+ 8 - 1
apps/electron/package.json

@@ -29,7 +29,10 @@
     "modula2-project": "0.1.0",
     "modula2-workbench": "0.1.0",
     "react": "^19.0.0",
-    "react-dom": "^19.0.0"
+    "react-dom": "^19.0.0",
+    "pascal-compiler": "0.2.0",
+    "pascal-debug": "0.1.0",
+    "pascal-workbench": "0.1.0"
   },
   "devDependencies": {
     "@theia/cli": "1.75.0",
@@ -64,6 +67,10 @@
       {
         "from": "../../extensions/modula2-debug/lib/adapter",
         "to": "adapter"
+      },
+      {
+        "from": "../../extensions/pascal-debug/lib/adapter",
+        "to": "adapter-pascal"
       }
     ],
     "linux": {

+ 16 - 0
extensions/pascal-compiler/README.md

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

+ 39 - 0
extensions/pascal-compiler/package.json

@@ -0,0 +1,39 @@
+{
+  "name": "pascal-compiler",
+  "displayName": "Pascal Compiler Support",
+  "version": "0.2.0",
+  "keywords": [
+    "theia-extension"
+  ],
+  "files": [
+    "lib",
+    "src"
+  ],
+  "dependencies": {
+    "@theia/core": "1.75.0",
+    "@theia/editor": "1.75.0",
+    "@theia/filesystem": "1.75.0",
+    "@theia/markers": "1.75.0",
+    "@theia/output": "1.75.0",
+    "@theia/task": "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/pascal-compiler-frontend-module"
+    },
+    {
+      "backend": "lib/node/pascal-compiler-backend-module"
+    }
+  ]
+}

+ 101 - 0
extensions/pascal-compiler/src/browser/pascal-build-dialog.ts

@@ -0,0 +1,101 @@
+import { codicon } from '@theia/core/lib/browser';
+import { AbstractDialog, DialogProps } from '@theia/core/lib/browser';
+import { BuildResult, BuildStats, formatBytes } from '../common/compiler';
+
+/** Centered build report with a status icon, statistics and an OK button. */
+export class BuildReportDialog extends AbstractDialog<void> {
+  protected readonly success: boolean;
+  protected readonly rows: Array<[string, string]>;
+  protected readonly details?: string;
+
+  constructor(result: BuildResult, projectName: string) {
+    super({
+      title: result.success ? 'Build succeeded' : 'Build failed',
+      maxWidth: 480,
+    } as DialogProps);
+    this.success = result.success;
+    this.rows = reportRows(result, projectName);
+    this.details = result.success ? undefined : failureDetails(result);
+    this.contentNode.appendChild(this.renderBody());
+    const ok = this.appendAcceptButton('OK');
+    ok.classList.add('main');
+  }
+
+  get value(): void {
+    return undefined;
+  }
+
+  protected renderBody(): HTMLElement {
+    const wrap = document.createElement('div');
+    wrap.style.display = 'flex';
+    wrap.style.gap = '16px';
+    wrap.style.alignItems = 'flex-start';
+    wrap.style.padding = '4px 2px';
+    const icon = document.createElement('div');
+    icon.className = codicon(this.success ? 'check' : 'error');
+    icon.style.fontSize = '40px';
+    icon.style.color = this.success ? '#89D185' : '#F14C4C';
+    icon.style.lineHeight = '1';
+    wrap.appendChild(icon);
+    const right = document.createElement('div');
+    right.style.flex = '1';
+    right.style.minWidth = '0';
+    for (const [label, value] of this.rows) {
+      const row = document.createElement('div');
+      row.style.display = 'flex';
+      row.style.justifyContent = 'space-between';
+      row.style.gap = '24px';
+      row.style.padding = '2px 0';
+      const left = document.createElement('span');
+      left.textContent = label;
+      left.style.opacity = '0.7';
+      const val = document.createElement('span');
+      val.textContent = value;
+      val.style.fontWeight = '600';
+      val.style.textAlign = 'right';
+      row.appendChild(left);
+      row.appendChild(val);
+      right.appendChild(row);
+    }
+    if (this.details) {
+      const pre = document.createElement('pre');
+      pre.textContent = this.details;
+      pre.style.marginTop = '12px';
+      pre.style.maxHeight = '160px';
+      pre.style.overflow = 'auto';
+      pre.style.fontSize = '12px';
+      pre.style.whiteSpace = 'pre-wrap';
+      right.appendChild(pre);
+    }
+    wrap.appendChild(right);
+    return wrap;
+  }
+}
+
+function reportRows(result: BuildResult, projectName: string): Array<[string, string]> {
+  const errors = result.diagnostics.filter(d => d.severity === 'error').length;
+  const warnings = result.diagnostics.filter(d => d.severity === 'warning').length;
+  if (!result.success) {
+    return [
+      ['Project', projectName],
+      ['Errors', String(errors)],
+      ['Warnings', String(warnings)],
+    ];
+  }
+  const stats: BuildStats | undefined = result.stats;
+  return [
+    ['Project', projectName],
+    ['Sources', stats ? `${stats.files} Pascal file${stats.files === 1 ? '' : 's'}` : '—'],
+    ['Lines', stats ? stats.lines.toLocaleString('en-US') : '—'],
+    ['Executable', stats ? formatBytes(stats.outputSize) : '—'],
+  ];
+}
+
+function failureDetails(result: BuildResult): string {
+  const lines = result.diagnostics.slice(0, 5)
+    .map(d => `${d.file}:${d.line + 1}:${d.character + 1}: ${d.message}`);
+  if (lines.length === 0 && result.output.trim().length > 0) {
+    return result.output.trim().split('\n').slice(0, 5).join('\n');
+  }
+  return lines.join('\n');
+}

+ 244 - 0
extensions/pascal-compiler/src/browser/pascal-compiler-frontend-module.ts

@@ -0,0 +1,244 @@
+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 { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
+import { codicon } from '@theia/core/lib/browser';
+import { Widget } from '@theia/core/lib/browser';
+import { EditorWidget } from '@theia/editor/lib/browser';
+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 { ProblemManager } from '@theia/markers/lib/browser/problem/problem-manager';
+import { Diagnostic, DiagnosticSeverity } from '@theia/core/shared/vscode-languageserver-protocol';
+import { PreferenceContribution } from '@theia/core/lib/common/preferences/preference-schema';
+import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service';
+import { OutputChannelManager } from '@theia/output/lib/browser/output-channel';
+import { TaskContribution } from '@theia/task/lib/browser/task-contribution';
+import URI from '@theia/core/lib/common/uri';
+import {
+  BuildDiagnostic, BuildResult, PascalCompiler, PascalCompilerServicePath,
+  PascalProject,
+} from '../common/compiler';
+import { BuildReportDialog } from './pascal-build-dialog';
+import { COMPILER_OPTIONS, COMPILER_PATH, DIALECT, pascalPreferenceSchema } from './pascal-preferences';
+import { loadWorkspaceProject } from './pascal-project';
+import { PascalTaskContribution, PascalTaskProvider, PascalTaskResolver } from './pascal-tasks';
+
+const CATEGORY = 'Pascal';
+
+/** Marker owner for gm2 build diagnostics shown in the Problems view. */
+const MARKER_OWNER = 'pascal-fpc';
+
+function severityOf(d: BuildDiagnostic): DiagnosticSeverity {
+  switch (d.severity) {
+    case 'error': return DiagnosticSeverity.Error;
+    case 'warning': return DiagnosticSeverity.Warning;
+    default: return DiagnosticSeverity.Information;
+  }
+}
+
+const Commands = {
+  build: Command.toLocalizedCommand({ id: 'pascal.build', label: 'Build', category: CATEGORY }),
+  buildFile: Command.toLocalizedCommand({ id: 'pascal.buildFile', label: 'Build File', category: CATEGORY }),
+  clean: Command.toLocalizedCommand({ id: 'pascal.clean', label: 'Clean', category: CATEGORY }),
+  rebuild: Command.toLocalizedCommand({ id: 'pascal.rebuild', label: 'Rebuild', category: CATEGORY }),
+  run: Command.toLocalizedCommand({ id: 'pascal.run', label: 'Run', category: CATEGORY }),
+  detect: Command.toLocalizedCommand({ id: 'pascal.detectCompiler', label: 'Detect Free Pascal', category: CATEGORY }),
+};
+
+@injectable()
+class CompilerCommands implements CommandContribution {
+  @inject(PascalCompiler) protected readonly compiler: PascalCompiler;
+  @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(ProblemManager) protected readonly problems: ProblemManager;
+  @inject(PreferenceService) protected readonly prefs: PreferenceService;
+  @inject(OutputChannelManager) protected readonly outputChannels: OutputChannelManager;
+  /** URIs currently carrying our markers, so stale ones can be cleared. */
+  protected readonly markedUris = new Set<string>();
+
+  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 pascal.json from the first workspace root, applying preferences. */
+  protected async loadProject(): Promise<PascalProject | undefined> {
+    const loaded = await loadWorkspaceProject(this.files, this.workspace);
+    if (!loaded) {
+      this.messages.warn('Pascal: open a folder with a pascal.json project first.');
+      return undefined;
+    }
+    const project = loaded.project;
+    // Preferences fill the gaps: an explicit pascal.json path always wins.
+    if (!project.compilerPathExplicit) {
+      project.compiler.path = this.prefs.get<string>(COMPILER_PATH, 'fpc');
+    }
+    const extra = this.prefs.get<string[]>(COMPILER_OPTIONS, []);
+    const dialect = this.prefs.get<string>(DIALECT, 'fpc');
+    project.compiler.options = [`-M${dialect}`, ...project.compiler.options, ...extra];
+    return project;
+  }
+
+  protected async withProject(op: (project: PascalProject) => Promise<void>): Promise<void> {
+    const project = await this.loadProject();
+    if (project) {
+      try {
+        await op(project);
+      } catch (error) {
+        this.messages.error(`Pascal: ${error instanceof Error ? error.message : String(error)}`);
+      }
+    }
+  }
+
+  /** Publish build diagnostics as Problems markers (click-to-navigate). */
+  protected setProblemMarkers(project: PascalProject, diagnostics: BuildDiagnostic[]): void {
+    this.clearProblemMarkers();
+    const rootUri = new URI(`file://${project.root}`);
+    const byFile = new Map<string, Diagnostic[]>();
+    for (const d of diagnostics) {
+      const uri = d.file.startsWith('/') ? new URI(`file://${d.file}`) : rootUri.resolve(d.file);
+      const list = byFile.get(uri.toString()) ?? [];
+      list.push({
+        range: {
+          start: { line: d.line, character: d.character },
+          end: { line: d.line, character: d.character + 1 },
+        },
+        severity: severityOf(d),
+        message: d.message,
+        source: 'fpc',
+      });
+      byFile.set(uri.toString(), list);
+    }
+    for (const [key, list] of byFile) {
+      this.problems.setMarkers(new URI(key), MARKER_OWNER, list);
+      this.markedUris.add(key);
+    }
+  }
+
+  protected clearProblemMarkers(): void {
+    for (const key of this.markedUris) {
+      this.problems.cleanAllMarkers(new URI(key));
+    }
+    this.markedUris.clear();
+  }
+
+  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}`;
+  }
+
+  /** Append the exact command plus its output to the Pascal channel. */
+  protected logCommand(command: string, output: string): void {
+    const channel = this.outputChannels.getChannel('Pascal');
+    channel.appendLine(`$ ${command}`);
+    if (output.length > 0) channel.append(output.endsWith('\n') ? output : output + '\n');
+  }
+
+  protected async build(project: PascalProject): Promise<void> {
+    const result = await this.compiler.build(project);
+    this.setProblemMarkers(project, result.diagnostics);
+    this.logCommand(result.command, result.output);
+    await new BuildReportDialog(result, project.name).open();
+  }
+
+  /** Build the Pascal 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 === '.pas' || uri.path.ext === '.pp')) {
+      this.messages.warn('Pascal: open a .pas or .pp 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: PascalProject): Promise<void> {
+    await this.compiler.clean(project);
+    this.clearProblemMarkers();
+    this.messages.info('Pascal: cleaned build outputs.');
+  }
+
+  protected async run(project: PascalProject): Promise<void> {
+    const result = await this.compiler.run(project);
+    this.setProblemMarkers(project, result.diagnostics);
+    // The build step already logged its command; log the run itself.
+    this.logCommand(result.command, result.output);
+    this.outputChannels.getChannel('Pascal').show();
+    if (!result.success && result.diagnostics.length > 0) {
+      const first = result.diagnostics.slice(0, 3)
+        .map(d => `${d.file}:${d.line + 1}:${d.character + 1}: ${d.message}`).join('\n');
+      this.messages.error(`Pascal: build failed.\n${first}`);
+      return;
+    }
+    this.messages.info(`Pascal: program exited with code ${result.exitCode}.`);
+  }
+
+  protected async detect(): Promise<void> {
+    const configured = this.prefs.get<string>(COMPILER_PATH, 'fpc');
+    const info = await this.compiler.detect(configured);
+    if (info) {
+      this.messages.info(`Free Pascal detected: ${info.version} (${info.path})`);
+    } else {
+      this.messages.warn(`Pascal: no working compiler at "${configured}".`);
+    }
+  }
+}
+
+@injectable()
+class CompilerToolbar implements TabBarToolbarContribution {
+  registerToolbarItems(registry: TabBarToolbarRegistry): void {
+    // Editor toolbar buttons, visible only for Pascal files in editors.
+    // The `when` clause follows the active file; `isVisible` additionally
+    // restricts to editor widgets so the buttons do not leak into the
+    // Outline and other side views sharing the same tab-bar toolbar.
+    const when = 'resourceExtname == .pas || resourceExtname == .pp || resourceExtname == .p';
+    const editorsOnly = (widget?: Widget): boolean => widget instanceof EditorWidget;
+    registry.registerItem({
+      id: 'pascal.build.toolbar',
+      command: Commands.build.id,
+      icon: codicon('tools'),
+      tooltip: 'Pascal: Build',
+      when,
+      isVisible: editorsOnly,
+      priority: 1,
+    });
+    registry.registerItem({
+      id: 'pascal.run.toolbar',
+      command: Commands.run.id,
+      icon: codicon('play'),
+      tooltip: 'Pascal: Run',
+      when,
+      isVisible: editorsOnly,
+      priority: 2,
+    });
+  }
+}
+
+export default new ContainerModule(bind => {
+  bind(PreferenceContribution).toConstantValue({ schema: pascalPreferenceSchema });
+  bind(PascalCompiler).toDynamicValue(ctx => {
+    const provider = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
+    return provider.createProxy<PascalCompiler>(PascalCompilerServicePath);
+  }).inSingletonScope();
+  bind(CommandContribution).to(CompilerCommands).inSingletonScope();
+  bind(TabBarToolbarContribution).to(CompilerToolbar).inSingletonScope();
+  bind(PascalTaskProvider).toSelf().inSingletonScope();
+  bind(PascalTaskResolver).toSelf().inSingletonScope();
+  bind(TaskContribution).to(PascalTaskContribution).inSingletonScope();
+});

+ 28 - 0
extensions/pascal-compiler/src/browser/pascal-preferences.ts

@@ -0,0 +1,28 @@
+import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
+
+export const COMPILER_PATH = 'pascal.compiler.path';
+export const COMPILER_OPTIONS = 'pascal.compiler.options';
+export const DIALECT = 'pascal.dialect';
+
+export const pascalPreferenceSchema: PreferenceSchema = {
+  title: 'Pascal',
+  properties: {
+    [COMPILER_PATH]: {
+      type: 'string',
+      default: 'fpc',
+      description: 'Pascal compiler executable (used when pascal.json sets no explicit path).',
+    },
+    [COMPILER_OPTIONS]: {
+      type: 'array',
+      items: { type: 'string' },
+      default: [],
+      description: 'Extra compiler options appended after pascal.json options.',
+    },
+    [DIALECT]: {
+      type: 'string',
+      enum: ['delphi', 'objfpc', 'fpc', 'tp', 'iso'],
+      default: 'fpc',
+      description: 'Free Pascal source mode, passed as -M<mode> ahead of all other options (fpc applies the last -M flag).',
+    },
+  },
+};

+ 19 - 0
extensions/pascal-compiler/src/browser/pascal-project.ts

@@ -0,0 +1,19 @@
+import { FileService } from '@theia/filesystem/lib/browser/file-service';
+import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
+import { PascalProject, parseProjectFile } from '../common/compiler';
+
+/** Load pascal.json from the first workspace root (shared by commands/tasks). */
+export async function loadWorkspaceProject(
+  files: FileService,
+  workspace: WorkspaceService,
+): Promise<{ project: PascalProject; rootUri: string } | undefined> {
+  const roots = await workspace.roots;
+  if (roots.length === 0) return undefined;
+  const rootUri = roots[0].resource;
+  try {
+    const content = await files.read(rootUri.resolve('pascal.json'));
+    return { project: parseProjectFile(rootUri.path.fsPath(), content.value), rootUri: rootUri.toString() };
+  } catch {
+    return undefined;
+  }
+}

+ 61 - 0
extensions/pascal-compiler/src/browser/pascal-tasks.ts

@@ -0,0 +1,61 @@
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { FileService } from '@theia/filesystem/lib/browser/file-service';
+import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
+import {
+  TaskContribution, TaskProvider, TaskProviderRegistry,
+  TaskResolver, TaskResolverRegistry,
+} from '@theia/task/lib/browser/task-contribution';
+import { TaskConfiguration } from '@theia/task/lib/common/task-protocol';
+import { PanelKind, RevealKind } from '@theia/task/lib/common/task-protocol';
+import { defaultOutput, buildProvidedTasks } from '../common/compiler';
+import { loadWorkspaceProject } from './pascal-project';
+
+/** Task type for Pascal tasks; resolved to shell execution below. */
+export const PASCAL_TASK_TYPE = 'pascal';
+
+/** Provides Build/Clean/Rebuild/Run tasks from pascal.json. */
+@injectable()
+export class PascalTaskProvider implements TaskProvider {
+  @inject(FileService) protected readonly files: FileService;
+  @inject(WorkspaceService) protected readonly workspace: WorkspaceService;
+
+  async provideTasks(): Promise<TaskConfiguration[]> {
+    const loaded = await loadWorkspaceProject(this.files, this.workspace);
+    if (!loaded) return [];
+    const { project, rootUri } = loaded;
+    return buildProvidedTasks(project).map(t => ({
+      label: t.label,
+      group: t.group === undefined ? 'none' : t.group === 'clean' ? 'clean' : { kind: t.group.kind, isDefault: t.group.isDefault ?? false },
+      command: t.command,
+      args: t.args,
+      dependsOn: t.dependsOn,
+      type: PASCAL_TASK_TYPE,
+      executionType: 'shell' as const,
+      options: { cwd: project.root },
+      presentation: { echo: true, reveal: RevealKind.Always, focus: false, panel: PanelKind.Shared },
+      _scope: rootUri,
+    }));
+  }
+}
+
+/** Resolves Pascal tasks to plain shell execution. */
+@injectable()
+export class PascalTaskResolver implements TaskResolver {
+  async resolveTask(taskConfig: TaskConfiguration): Promise<TaskConfiguration> {
+    return { ...taskConfig, type: 'shell' };
+  }
+}
+
+@injectable()
+export class PascalTaskContribution implements TaskContribution {
+  @inject(PascalTaskProvider) protected readonly provider: PascalTaskProvider;
+  @inject(PascalTaskResolver) protected readonly resolver: PascalTaskResolver;
+
+  registerProviders(providers: TaskProviderRegistry): void {
+    providers.register(PASCAL_TASK_TYPE, this.provider);
+  }
+
+  registerResolvers(resolvers: TaskResolverRegistry): void {
+    resolvers.registerTaskResolver(PASCAL_TASK_TYPE, this.resolver);
+  }
+}

+ 143 - 0
extensions/pascal-compiler/src/common/compiler.ts

@@ -0,0 +1,143 @@
+/** Shared contract for the Pascal compiler service (tutorial section 27).
+ *
+ *  The frontend never spawns the compiler directly: it calls this service
+ *  over RPC and the backend runs `fpc` asynchronously.
+ *  Other compilers can implement the same interface without changing the rest of the IDE.
+ */
+
+export const PascalCompilerServicePath = '/services/pascal-compiler';
+export const PascalCompiler = Symbol('PascalCompiler');
+
+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[];
+  /** Project source statistics and output size (present on success). */
+  stats?: BuildStats;
+}
+
+/** Source census for the build report dialog. */
+export interface BuildStats {
+  /** Number of `.pas`/`.pp` files under the project root. */
+  files: number;
+  /** Total lines across those files. */
+  lines: number;
+  /** Built executable size in bytes (-1 when unavailable). */
+  outputSize: number;
+}
+
+/** Human-readable byte count (`45.2 KB`, `3.1 MB`). Pure and testable. */
+export function formatBytes(bytes: number): string {
+  if (!Number.isFinite(bytes) || bytes < 0) return '—';
+  if (bytes < 1024) return `${bytes} B`;
+  const kb = bytes / 1024;
+  if (kb < 1024) return `${kb.toFixed(1)} KB`;
+  return `${(kb / 1024).toFixed(1)} MB`;
+}
+
+export interface CompilerOptions {
+  /** Compiler executable, e.g. "fpc" or an absolute path. */
+  path: string;
+  options: string[];
+}
+
+export interface PascalProject {
+  name: string;
+  /** Absolute workspace folder containing pascal.json. */
+  root: string;
+  /** Main file, relative to root (e.g. "src/Main.pas"). */
+  main: string;
+  /** Built executable, relative to root. Defaults to bin/<name>. */
+  output?: string;
+  compiler: CompilerOptions;
+  /** True when pascal.json sets an explicit compiler path. */
+  compilerPathExplicit: boolean;
+}
+
+export interface CompilerInfo {
+  path: string;
+  /** First line of `fpc --version`. */
+  version: string;
+}
+
+export interface PascalCompiler {
+  /** 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: PascalProject): Promise<BuildResult>;
+  /** Remove build outputs (best-effort). */
+  clean(project: PascalProject): Promise<void>;
+  /** Build, then execute the produced program and capture its output. */
+  run(project: PascalProject): Promise<BuildResult>;
+}
+
+/** Minimal `pascal.json` reader (tutorial sections 23/33). Pure and testable. */
+export function parseProjectFile(root: string, jsonText: string): PascalProject {
+  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('pascal.json: missing "name".');
+  }
+  if (typeof raw.main !== 'string' || raw.main.length === 0) {
+    throw new Error('pascal.json: missing "main".');
+  }
+  const compiler = raw.compiler ?? {};
+  const options = Array.isArray(compiler.options)
+    ? compiler.options.filter((o): o is string => typeof o === 'string')
+    : [];
+  const explicitPath = typeof compiler.path === 'string' && compiler.path.length > 0;
+  return {
+    name: raw.name,
+    root,
+    main: raw.main,
+    output: typeof raw.output === 'string' ? raw.output : undefined,
+    compiler: {
+      path: explicitPath ? (compiler.path as string) : 'fpc',
+      options,
+    },
+    compilerPathExplicit: explicitPath,
+  };
+}
+
+/** Default executable path for a project (bin/<name> under root). */
+export function defaultOutput(project: PascalProject): string {
+  return project.output ?? `bin/${project.name}`;
+}
+
+export interface ProvidedTask {
+  label: string;
+  group?: { kind: 'build' | 'rebuild'; isDefault?: boolean } | 'clean';
+  command: string;
+  args: string[];
+  dependsOn?: string;
+}
+
+/** Pure task list for a project (tested headlessly; the Theia provider wraps these). */
+export function buildProvidedTasks(project: PascalProject): ProvidedTask[] {
+  const out = defaultOutput(project);
+  const buildArgs = ['-g', ...project.compiler.options, '-o' + out, project.main];
+  return [
+    { label: 'Pascal: Build', group: { kind: 'build', isDefault: true }, command: project.compiler.path, args: buildArgs },
+    { label: 'Pascal: Clean', group: 'clean', command: 'rm', args: ['-f', out] },
+    { label: 'Pascal: Rebuild', group: { kind: 'rebuild' }, command: project.compiler.path, args: buildArgs },
+    { label: 'Pascal: Run', command: `./${out}`, args: [], dependsOn: 'Pascal: Build' },
+  ];
+}

+ 176 - 0
extensions/pascal-compiler/src/node/compiler-service.ts

@@ -0,0 +1,176 @@
+/** Free Pascal implementation of the compiler service.
+ *
+ *  Runs `fpc` as a child process (never blocking the frontend) and turns
+ *  its `file(line,col) severity: message` output into structured
+ *  diagnostics. `parseFpcDiagnostics` 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, BuildStats, CompilerInfo, PascalCompiler,
+  PascalProject, defaultOutput,
+} from '../common/compiler';
+
+export interface ProcessResult {
+  exitCode: number;
+  output: string;
+}
+
+/** Source census plus output size for the build report (best-effort). */
+export async function collectBuildStats(
+  project: PascalProject, out: string,
+): Promise<BuildStats> {
+  let files = 0;
+  let lines = 0;
+  const visit = async (dir: string, depth: number): Promise<void> => {
+    if (depth > 8) return;
+    let entries: import('fs').Dirent[];
+    try {
+      entries = await fs.promises.readdir(dir, { withFileTypes: true });
+    } catch {
+      return;
+    }
+    for (const entry of entries) {
+      const full = path.join(dir, entry.name);
+      if (entry.isDirectory()) {
+        if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
+        await visit(full, depth + 1);
+      } else if (entry.name.endsWith('.pas') || entry.name.endsWith('.pp')) {
+        files++;
+        try {
+          const text = await fs.promises.readFile(full, 'utf8');
+          lines += text.split('\n').length;
+        } catch {
+          // Count the file even when its lines cannot be read.
+        }
+      }
+    }
+  };
+  await visit(project.root, 0);
+  let outputSize = -1;
+  try {
+    outputSize = (await fs.promises.stat(path.join(project.root, out))).size;
+  } catch {
+    // Leave unavailable.
+  }
+  return { files, lines, outputSize };
+}
+
+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 FPC_LINE = /^(.+?)\((\d+),(\d+)\)\s+(Error|Warning|Fatal|Note|Hint):\s+(.*)$/;
+
+/** Parse FPC-style diagnostics; context/caret lines are ignored. */
+export function parseFpcDiagnostics(output: string): BuildDiagnostic[] {
+  const diagnostics: BuildDiagnostic[] = [];
+  for (const line of output.split('\n')) {
+    const m = FPC_LINE.exec(line);
+    if (!m) continue;
+    const severity = m[4].toLowerCase().includes('error') || m[4] === 'Fatal' ? '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 FreePascalCompilerService implements PascalCompiler {
+  async detect(compilerPath = 'fpc'): Promise<CompilerInfo | undefined> {
+    try {
+      const result = await runProcess(compilerPath, ['-iV'], 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: PascalProject): Promise<BuildResult> {
+    const out = defaultOutput(project);
+    await fs.promises.mkdir(path.dirname(path.join(project.root, out)), { recursive: true });
+    const args = ['-g', ...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: [],
+      };
+    }
+    const success = result.exitCode === 0;
+    return {
+      success,
+      exitCode: result.exitCode,
+      command,
+      output: result.output,
+      diagnostics: parseFpcDiagnostics(result.output),
+      stats: success ? await collectBuildStats(project, out) : undefined,
+    };
+  }
+
+  async clean(project: PascalProject): 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('.ppu'))
+      .map(e => fs.promises.rm(path.join(srcDir, e), { force: true })));
+  }
+
+  async run(project: PascalProject): 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/pascal-compiler/src/node/pascal-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 { PascalCompiler, PascalCompilerServicePath } from '../common/compiler';
+import { FreePascalCompilerService } from './compiler-service';
+
+export default new ContainerModule(bind => {
+  bind(PascalCompiler).to(FreePascalCompilerService).inSingletonScope();
+  bind(ConnectionHandler).toDynamicValue(ctx =>
+    new RpcConnectionHandler<PascalCompiler>(
+      PascalCompilerServicePath,
+      () => ctx.container.get<PascalCompiler>(PascalCompiler),
+    )).inSingletonScope();
+});

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

+ 17 - 0
extensions/pascal-debug/README.md

@@ -0,0 +1,17 @@
+# pascal-debug
+
+GDB-backed Debug Adapter Protocol integration for Pascal.
+
+- `src/adapter/mi.ts`: minimal GDB/MI output parser (plain Node, tested
+  against live captures).
+- `src/adapter/gdb-adapter.ts`: DAP adapter driving `gdb --interpreter=mi`
+  over stdio (launch, breakpoints + conditions, continue, next, stepIn,
+  stepOut, pause, threads, stackTrace, Locals + Module scopes with record
+  expansion, evaluate, disconnect).
+- `src/node/debug-adapter-contribution.ts`: backend `DebugAdapterContribution`
+  for debug type `pascal` (schema, default config from `pascal.json`,
+  fork executable).
+
+Usage: build with `gm2 -g` (source lines), open the Run and Debug view,
+create a `pascal` configuration (program defaults to `bin/<name>` from
+`pascal.json`), set breakpoints in `.mod` files, press F5.

+ 14 - 0
extensions/pascal-debug/package.json

@@ -0,0 +1,14 @@
+{
+  "name": "pascal-debug",
+  "displayName": "Pascal Debugger (GDB)",
+  "version": "0.1.0",
+  "keywords": ["theia-extension"],
+  "files": ["lib", "src"],
+  "dependencies": {
+    "@theia/core": "1.75.0",
+    "@theia/debug": "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": [{"backend": "lib/node/pascal-debug-backend-module"}]
+}

+ 672 - 0
extensions/pascal-debug/src/adapter/gdb-adapter.ts

@@ -0,0 +1,672 @@
+/** DAP debug adapter for Pascal programs, driving `gdb --interpreter=mi`.
+ *
+ *  Plain Node.js, no Theia (or other) dependencies: Theia spawns this file
+ *  as the `pascal` debug adapter executable and talks DAP over stdio.
+ *
+ *  Supported DAP surface (deliberately small, all verified live):
+ *  initialize, launch, setBreakpoints (+ conditions), configurationDone,
+ *  threads, stackTrace, scopes (Locals), variables (with structure
+ *  expansion), evaluate, continue, next, stepIn, stepOut, pause, disconnect.
+ *  Everything else answers "unsupported" instead of hanging.
+ */
+
+import { spawn, ChildProcess, spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+  MIRecord, MIValue, asFields, asList, asString, classifyLine, fieldString, isRecord,
+} from './mi';
+
+/** Module-level variables from DWARF (frame queries only see procedure locals).
+ *
+ *  gm2 emits module `VAR`s as top-level `DW_TAG_variable` DIEs. A single
+ *  `readelf` scan per program collects them for compilation units backed by
+ *  `.mod` files. Best effort: any failure yields an empty list and the
+ *  Module scope is simply omitted (hover/evaluate still resolve every name).
+ */
+export function readModuleGlobals(program: string): string[] {
+  try {
+    const out = spawnSync('readelf', ['--debug-dump=info', program], {
+      encoding: 'utf8',
+      env: { ...process.env, LC_ALL: 'C' },
+      timeout: 15000,
+    });
+    if (out.status !== 0 || !out.stdout) return [];
+    return scanVariables(out.stdout);
+  } catch {
+    return [];
+  }
+}
+
+/** Second pass over the dump: collect depth-1 variables of .mod units. */
+function scanVariables(dump: string): string[] {
+  const names: string[] = [];
+  const seen = new Set<string>();
+  let inModUnit = false;
+  let expectVarName = false;
+  for (const line of dump.split('\n')) {
+    const die = /^ <(\d+)><[^>]+>:\s*Abbrev Number:\s*\d+\s*\((DW_TAG_\w+)\)/.exec(line);
+    if (die) {
+      const [, depth, tag] = die;
+      if (tag === 'DW_TAG_compile_unit' || tag === 'DW_TAG_partial_unit') {
+        inModUnit = false;
+        expectVarName = false;
+      } else if (tag === 'DW_TAG_variable' && depth === '1' && inModUnit) {
+        expectVarName = true;
+      } else {
+        expectVarName = false;
+      }
+      continue;
+    }
+    const nm = /DW_AT_name\s*:\s*(?:\([^)]*\)\s*:\s*)?(\S+)\s*$/.exec(line);
+    if (!nm) continue;
+    const name = nm[1];
+    if (expectVarName) {
+      expectVarName = false;
+      if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !seen.has(name)) {
+        seen.add(name);
+        names.push(name);
+      }
+    } else if (name.endsWith('.pas') || name.endsWith('.pp')) {
+      // A CU name: the next depth-1 variables belong to this unit.
+      // (CU DIE precedes its children; runtime units never end with .pas.)
+      inModUnit = true;
+    }
+  }
+  return names;
+}
+
+interface DapMessage {
+  seq: number;
+  type: string;
+  command?: string;
+  arguments?: Record<string, unknown>;
+  request_seq?: number;
+  success?: boolean;
+  message?: string;
+  body?: unknown;
+  event?: string;
+}
+
+interface Breakpoint {
+  line: number;
+  number: number;
+  condition?: string;
+}
+
+interface FrameRef {
+  threadId: number;
+  level: number;
+}
+
+type VarHandle = {
+  kind: 'locals';
+  threadId: number;
+  level: number;
+  frameId: number;
+} | {
+  kind: 'varobj';
+  name: string;
+} | {
+  kind: 'module';
+};
+
+const SCALAR_TYPES = new Set([
+  'INTEGER', 'CARDINAL', 'LONGINT', 'SHORTINT', 'LONGCARD', 'SHORTCARD',
+  'SMALLINT', 'INT64', 'QWORD', 'DWORD', 'NATIVEINT', 'NATIVESIZE',
+  'REAL', 'LONGREAL', 'SINGLE', 'DOUBLE', 'EXTENDED', 'CURRENCY',
+  'CHAR', 'ANSICHAR', 'WIDECHAR', 'BOOLEAN', 'BYTEBOOL', 'WORDBOOL', 'LONGBOOL',
+  'BITSET', 'WORD', 'BYTE', 'SHORTSTRING', 'STRING',
+]);
+
+class Adapter {
+  private seq = 0;
+  private buffer = '';
+  private gdb: ChildProcess | null = null;
+  private gdbBuffer = '';
+  private token = 0;
+  private pending = new Map<string, (rec: MIRecord) => void>();
+  private running = false;
+  private started = false;
+  private exited = false;
+  private program = '';
+  private moduleVars: string[] = [];
+  private entryBp: number | null = null;
+  private breakpoints = new Map<string, Breakpoint[]>();
+  private threadsCache: Array<{ id: number; name: string }> = [{ id: 1, name: 'main' }];
+  private frames = new Map<number, FrameRef>();
+  private handles = new Map<number, VarHandle>();
+  private nextHandle = 1000;
+  private varObjs = new Set<string>();
+  private varSeq = 0;
+
+  run(): void {
+    process.stdin.on('data', d => this.onDapData(d.toString('utf8')));
+    process.stdin.on('end', () => this.shutdown());
+  }
+
+  // ---- DAP framing ----
+
+  private onDapData(chunk: string): void {
+    this.buffer += chunk;
+    for (;;) {
+      const head = /Content-Length:\s*(\d+)\r\n\r\n/.exec(this.buffer);
+      if (!head) return;
+      const n = Number(head[1]);
+      const start = head.index + head[0].length;
+      if (this.buffer.length < start + n) return;
+      const body = this.buffer.slice(start, start + n);
+      this.buffer = this.buffer.slice(start + n);
+      try {
+        this.dispatch(JSON.parse(body) as DapMessage);
+      } catch {
+        // Ignore malformed input.
+      }
+    }
+  }
+
+  private send(msg: object): void {
+    const body = JSON.stringify(msg);
+    process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
+  }
+
+  private respond(req: DapMessage, body?: unknown, success = true, message?: string): void {
+    this.send({
+      seq: ++this.seq, type: 'response', request_seq: req.seq,
+      success, command: req.command, message, body: body ?? {},
+    });
+  }
+
+  private event(event: string, body?: unknown): void {
+    this.send({ seq: ++this.seq, type: 'event', event, body: body ?? {} });
+  }
+
+  // ---- GDB/MI transport ----
+
+  private mi(command: string): Promise<MIRecord> {
+    return new Promise(resolve => {
+      if (!this.gdb?.stdin) {
+        resolve({ token: '', marker: '^', cls: 'error', rest: '', fields: { msg: 'gdb is not running' } });
+        return;
+      }
+      this.token++;
+      const tok = String(this.token);
+      this.pending.set(tok, resolve);
+      this.gdb.stdin.write(`${tok}${command}\n`);
+    });
+  }
+
+  private miOk(rec: MIRecord): boolean {
+    return rec.marker === '^' && rec.cls === 'done';
+  }
+
+  private miError(rec: MIRecord): string {
+    return fieldString(rec.fields, 'msg') ?? 'gdb command failed';
+  }
+
+  private onGdbData(chunk: string): void {
+    this.gdbBuffer += chunk;
+    const lines = this.gdbBuffer.split('\n');
+    this.gdbBuffer = lines.pop() ?? '';
+    for (const line of lines) this.onGdbLine(line);
+  }
+
+  private onGdbLine(line: string): void {
+    if (!line) return;
+    const parsed = classifyLine(line);
+    if (!isRecord(parsed)) {
+      if ('prompt' in parsed) return;
+      this.event('output', { category: 'stdout', output: (parsed as { output: string }).output + '\n' });
+      return;
+    }
+    if (parsed.marker === '~' || parsed.marker === '&') return; // gdb chatter
+    if (parsed.marker === '@') {
+      this.event('output', { category: 'stdout', output: fieldString(parsed.fields, 'text') ?? '' });
+      return;
+    }
+    if (parsed.marker === '^' || parsed.marker === '*') {
+      if (parsed.token && this.pending.has(parsed.token)) {
+        const resolve = this.pending.get(parsed.token)!;
+        this.pending.delete(parsed.token);
+        resolve(parsed);
+        return;
+      }
+    }
+    if (parsed.marker === '*') this.onExecAsync(parsed);
+  }
+
+  private onExecAsync(rec: MIRecord): void {
+    if (rec.cls !== 'stopped') return;
+    this.running = false;
+    const reason = fieldString(rec.fields, 'reason') ?? '';
+    const threadId = Number(fieldString(rec.fields, 'thread-id') ?? '1') || 1;
+    if (reason === 'exited-normally' || reason === 'exited' || reason === 'exited-signalled') {
+      this.event('exited', { exitCode: reason === 'exited-normally' ? 0 : 1 });
+      this.event('terminated', { restart: false });
+      return;
+    }
+    // Breakpoints requested before the temporary entry breakpoint resolves first.
+    const bkptno = Number(fieldString(rec.fields, 'bkptno') ?? '0');
+    if (this.entryBp !== null && bkptno === this.entryBp) {
+      void this.mi(`-break-delete ${this.entryBp}`);
+      this.entryBp = null;
+      this.stoppedEvent(threadId, 'entry', rec);
+      return;
+    }
+    const map: { [k: string]: string } = {
+      'breakpoint-hit': 'breakpoint',
+      'end-stepping-range': 'step',
+      'function-finished': 'step',
+      'signal-received': 'exception',
+    };
+    this.stoppedEvent(threadId, map[reason] ?? 'pause', rec);
+  }
+
+  private stoppedEvent(threadId: number, reason: string, rec: MIRecord): void {
+    // Drop cached varobjs: frames may have changed.
+    for (const name of this.varObjs) void this.mi(`-var-delete ${name}`);
+    this.varObjs.clear();
+    const frame = asFields(rec.fields['frame']);
+    const file = frame ? fieldString(frame, 'fullname') ?? fieldString(frame, 'file') : undefined;
+    const line = frame ? Number(fieldString(frame, 'line') ?? '0') : 0;
+    this.refreshThreads();
+    if (file && line > 0) {
+      this.event('stopped', {
+        reason, threadId, allThreadsStopped: true,
+        source: { name: path.basename(file), path: file }, line,
+      });
+    } else {
+      this.event('stopped', { reason, threadId, allThreadsStopped: true });
+    }
+  }
+
+  private refreshThreads(): void {
+    if (this.running) return;
+    void this.mi('-thread-info').then(rec => {
+      if (!this.miOk(rec)) return;
+      const out = this.parseThreads(rec);
+      if (out.length > 0) this.threadsCache = out;
+    });
+  }
+
+  // ---- DAP dispatch ----
+
+  private dispatch(req: DapMessage): void {
+    if (req.type !== 'request') return;
+    const a = (req.arguments ?? {}) as Record<string, any>;
+    switch (req.command) {
+      case 'initialize':
+        this.respond(req, {
+          supportsConfigurationDoneRequest: true,
+          supportsConditionalBreakpoints: true,
+          supportsEvaluateForHovers: true,
+        });
+        this.event('initialized');
+        return;
+      case 'launch': void this.onLaunch(req, a); return;
+      case 'setBreakpoints': void this.onSetBreakpoints(req, a); return;
+      case 'setExceptionBreakpoints':
+        // No exception breakpoints in Pascal: acknowledge with an empty set
+        // so Theia doesn't log a spurious error on every session start.
+        this.respond(req, { breakpoints: [] });
+        return;
+      case 'configurationDone': void this.onConfigurationDone(req); return;
+      case 'threads': void this.onThreads(req); return;
+      case 'stackTrace': void this.onStackTrace(req, a); return;
+      case 'scopes': this.onScopes(req, a); return;
+      case 'variables': void this.onVariables(req, a); return;
+      case 'evaluate': void this.onEvaluate(req, a); return;
+      case 'continue': void this.onContinue(req); return;
+      case 'next': void this.onStep(req, '-exec-next'); return;
+      case 'stepIn': void this.onStep(req, '-exec-step'); return;
+      case 'stepOut': void this.onStep(req, '-exec-finish'); return;
+      case 'pause': void this.onPause(req); return;
+      case 'disconnect': void this.onDisconnect(req); return;
+      default:
+        this.respond(req, undefined, false, `unsupported request: ${req.command}`);
+    }
+  }
+
+  private async onLaunch(req: DapMessage, args: Record<string, any>): Promise<void> {
+    const program = String(args['program'] ?? '');
+    if (!program) {
+      this.respond(req, undefined, false, 'launch: missing "program" (set it in launch.json or run Pascal: Build first)');
+      return;
+    }
+    try {
+      fs.accessSync(program, fs.constants.X_OK);
+    } catch {
+      this.respond(req, undefined, false, `launch: program not found or not executable: ${program} (build it with Pascal: Build, including -g for source lines)`);
+      return;
+    }
+    this.program = program;
+    this.lastLaunchArgs = args;
+    this.moduleVars = readModuleGlobals(program);
+    this.gdb = spawn('gdb', ['--interpreter=mi2'], { stdio: ['pipe', 'pipe', 'pipe'] });
+    this.gdb.stdout?.on('data', d => this.onGdbData(d.toString()));
+    this.gdb.stderr?.on('data', d => this.event('output', { category: 'stderr', output: d.toString() }));
+    this.gdb.on('close', () => this.onGdbClosed());
+    this.gdb.on('error', err => {
+      if (!this.exited) this.respond(req, undefined, false, `launch: cannot start gdb: ${String(err)}`);
+    });
+    // Let the process start before issuing MI commands.
+    await new Promise(r => setTimeout(r, 150));
+    if (!this.gdb) {
+      this.respond(req, undefined, false, 'launch: gdb failed to start');
+      return;
+    }
+    await this.mi('-gdb-set confirm off');
+    const file = await this.mi(`-file-exec-and-symbols "${program.replace(/"/g, '\\"')}"`);
+    if (!this.miOk(file)) {
+      this.respond(req, undefined, false, `launch: ${this.miError(file)}`);
+      return;
+    }
+    const cwd = String(args['cwd'] ?? '');
+    if (cwd) await this.mi(`-environment-cd "${cwd.replace(/"/g, '\\"')}"`);
+    const extra = Array.isArray(args['args']) ? args['args'].map(String) : [];
+    if (extra.length > 0) {
+      await this.mi(`-exec-arguments ${extra.map(s => (/\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s)).join(' ')}`);
+    }
+    this.respond(req, {});
+  }
+
+  private async onSetBreakpoints(req: DapMessage, args: Record<string, any>): Promise<void> {
+    const source = args['source'] as { path?: string } | undefined;
+    const file = source?.path;
+    const wanted = (Array.isArray(args['breakpoints']) ? args['breakpoints'] : []) as Array<{ line: number; condition?: string }>;
+    if (!file) {
+      this.respond(req, { breakpoints: [] });
+      return;
+    }
+    const ids: Breakpoint[] = [];
+    if (this.gdb) {
+      for (const old of this.breakpoints.get(file) ?? []) {
+        await this.mi(`-break-delete ${old.number}`);
+      }
+      for (const bp of wanted) {
+        const ins = await this.mi(`-break-insert -f "${file}:${bp.line}"`);
+        if (!this.miOk(ins)) {
+          ids.push({ line: bp.line, number: 0 });
+          continue;
+        }
+        const bkpt = asFields(ins.fields['bkpt']);
+        const number = Number(fieldString(bkpt ?? {}, 'number') ?? '0');
+        if (bp.condition) await this.mi(`-break-condition ${number} ${bp.condition}`);
+        ids.push({ line: bp.line, number, condition: bp.condition });
+      }
+      this.breakpoints.set(file, ids.filter(b => b.number > 0));
+    } else {
+      this.breakpoints.set(file, wanted.map(b => ({ line: b.line, number: 0, condition: b.condition })));
+    }
+    this.respond(req, {
+      breakpoints: ids.length > 0
+        ? ids.map(b => ({ id: b.number > 0 ? b.number : undefined, verified: b.number > 0, line: b.line }))
+        : wanted.map(b => ({ verified: false, line: b.line })),
+    });
+  }
+
+  private async onConfigurationDone(req: DapMessage): Promise<void> {
+    this.respond(req, {});
+    if (this.started || !this.gdb) return;
+    this.started = true;
+    // Breakpoints set before launch resolve now (normally already applied).
+    for (const [file, bps] of this.breakpoints) {
+      if (bps.some(b => b.number === 0)) {
+        for (const old of bps) {
+          if (old.number > 0) await this.mi(`-break-delete ${old.number}`);
+        }
+        const fresh: Breakpoint[] = [];
+        for (const bp of bps) {
+          const ins = await this.mi(`-break-insert -f "${file}:${bp.line}"`);
+          const bkpt = asFields(ins.fields['bkpt']);
+          const number = Number(fieldString(bkpt ?? {}, 'number') ?? '0');
+          if (number > 0) {
+            if (bp.condition) await this.mi(`-break-condition ${number} ${bp.condition}`);
+            fresh.push({ line: bp.line, number, condition: bp.condition });
+          }
+        }
+        this.breakpoints.set(file, fresh);
+      }
+    }
+    // Temporary entry breakpoint when requested (best effort: plain `main` first).
+    const stopOnEntry = (this.lastLaunchArgs['stopOnEntry'] as boolean) ?? false;
+    if (stopOnEntry) {
+      const ins = await this.mi('-break-insert -t main');
+      const bkpt = asFields(ins.fields['bkpt']);
+      this.entryBp = Number(fieldString(bkpt ?? {}, 'number') ?? '0') || null;
+    }
+    this.running = true;
+    await this.mi('-exec-run');
+  }
+
+  private lastLaunchArgs: Record<string, unknown> = {};
+
+  private async onThreads(req: DapMessage): Promise<void> {
+    if (this.gdb && !this.running) {
+      const rec = await this.mi('-thread-info');
+      if (this.miOk(rec)) this.threadsCache = this.parseThreads(rec);
+    }
+    this.respond(req, { threads: this.threadsCache.map(t => ({ id: t.id, name: t.name })) });
+  }
+
+  private parseThreads(rec: MIRecord): Array<{ id: number; name: string }> {
+    const out: Array<{ id: number; name: string }> = [];
+    for (const item of asList(rec.fields['threads'])) {
+      const f = asFields(item);
+      if (!f) continue;
+      const id = Number(fieldString(f, 'id') ?? '0') || 0;
+      out.push({ id, name: `Thread ${fieldString(f, 'id')}` });
+    }
+    return out;
+  }
+
+  private async onStackTrace(req: DapMessage, args: Record<string, any>): Promise<void> {
+    const threadId = Number(args['threadId'] ?? 1);
+    if (!this.gdb || this.running) {
+      this.respond(req, undefined, false, 'not stopped');
+      return;
+    }
+    const rec = await this.mi(`-stack-list-frames --thread ${threadId}`);
+    if (!this.miOk(rec)) {
+      this.respond(req, undefined, false, this.miError(rec));
+      return;
+    }
+    const frames: unknown[] = [];
+    for (const item of asList(rec.fields['stack'])) {
+      const wrapped = asFields(item);
+      const f = wrapped && 'frame' in wrapped ? asFields(wrapped['frame']) : wrapped;
+      if (!f) continue;
+      const level = Number(fieldString(f, 'level') ?? '0');
+      const id = threadId * 1000000 + level;
+      this.frames.set(id, { threadId, level });
+      const file = fieldString(f, 'fullname') ?? fieldString(f, 'file');
+      const line = Number(fieldString(f, 'line') ?? '0');
+      frames.push({
+        id,
+        name: fieldString(f, 'func') ?? `frame ${level}`,
+        source: file ? { name: path.basename(file), path: file } : undefined,
+        line: line > 0 ? line : 0,
+        column: 0,
+      });
+    }
+    this.respond(req, { stackFrames: frames, totalFrames: frames.length });
+  }
+
+  private onScopes(req: DapMessage, args: Record<string, any>): void {
+    const ref = this.frames.get(Number(args['frameId']));
+    if (!ref || !this.gdb || this.running) {
+      this.respond(req, undefined, false, 'not stopped');
+      return;
+    }
+    const handle = this.nextHandle++;
+    this.handles.set(handle, { kind: 'locals', threadId: ref.threadId, level: ref.level, frameId: Number(args['frameId']) });
+    const scopes: unknown[] = [{ name: 'Locals', variablesReference: handle, expensive: false }];
+    if (this.moduleVars.length > 0) {
+      const modHandle = this.nextHandle++;
+      this.handles.set(modHandle, { kind: 'module' });
+      scopes.push({ name: 'Module', variablesReference: modHandle, expensive: false });
+    }
+    this.respond(req, { scopes });
+  }
+
+  private async onVariables(req: DapMessage, args: Record<string, any>): Promise<void> {
+    const handle = this.handles.get(Number(args['variablesReference']));
+    if (!handle || !this.gdb || this.running) {
+      this.respond(req, undefined, false, 'not stopped');
+      return;
+    }
+    if (handle.kind === 'module') {
+      const out: unknown[] = [];
+      for (const name of this.moduleVars) {
+        const made = await this.makeVarObj(name);
+        out.push({ name, value: made?.value ?? '', type: made?.type ?? '', variablesReference: made?.ref ?? 0 });
+      }
+      this.respond(req, { variables: out });
+      return;
+    }
+    if (handle.kind === 'locals') {
+      const rec = await this.mi(`-stack-list-variables --thread ${handle.threadId} --frame ${handle.level} --simple-values`);
+      if (!this.miOk(rec)) {
+        this.respond(req, undefined, false, this.miError(rec));
+        return;
+      }
+      const out: unknown[] = [];
+      for (const item of asList(rec.fields['variables'])) {
+        const v = asFields(item);
+        if (!v) continue;
+        const name = fieldString(v, 'name') ?? '?';
+        const value = fieldString(v, 'value') ?? '';
+        const type = fieldString(v, 'type') ?? '';
+        let ref = 0;
+        if (!SCALAR_TYPES.has(type.toUpperCase()) && value !== '') {
+          ref = (await this.makeVarObj(name))?.ref ?? 0;
+        }
+        out.push({ name, value, type, variablesReference: ref });
+      }
+      this.respond(req, { variables: out });
+      return;
+    }
+    const rec = await this.mi(`-var-list-children --all-values ${handle.name}`);
+    if (!this.miOk(rec)) {
+      this.respond(req, undefined, false, this.miError(rec));
+      return;
+    }
+    const childrenField = rec.fields['children'];
+    const rawChildren = Array.isArray(childrenField)
+      ? childrenField
+      : asList(asFields(childrenField)?.['child']);
+    const out: unknown[] = [];
+    for (const item of rawChildren) {
+      const wrapped = asFields(item);
+      // gdb emits children=[child={...},child={...}]; unwrap single-key wrappers.
+      const v = wrapped && 'child' in wrapped ? asFields(wrapped['child']) ?? wrapped : wrapped;
+      if (!v) continue;
+      const name = fieldString(v, 'name') ?? '?';
+      const exp = fieldString(v, 'exp') ?? name;
+      const value = fieldString(v, 'value') ?? '';
+      const type = fieldString(v, 'type') ?? '';
+      const numchild = fieldString(v, 'numchild') ?? '0';
+      let ref = 0;
+      if (numchild !== '0') {
+        const h = this.nextHandle++;
+        this.handles.set(h, { kind: 'varobj', name });
+        ref = h;
+      }
+      out.push({ name: exp, value, type, variablesReference: ref });
+    }
+    this.respond(req, { variables: out });
+  }
+
+  private async makeVarObj(expression: string): Promise<{ ref: number; value: string; type: string } | null> {
+    this.varSeq++;
+    const name = `m2v${this.varSeq}`;
+    const rec = await this.mi(`-var-create ${name} * "${expression.replace(/"/g, '\\"')}"`);
+    if (!this.miOk(rec)) return null;
+    const value = fieldString(rec.fields, 'value') ?? '';
+    const type = fieldString(rec.fields, 'type') ?? '';
+    if ((fieldString(rec.fields, 'numchild') ?? '0') === '0') {
+      void this.mi(`-var-delete ${name}`);
+      return { ref: 0, value, type };
+    }
+    this.varObjs.add(name);
+    const h = this.nextHandle++;
+    this.handles.set(h, { kind: 'varobj', name });
+    return { ref: h, value, type };
+  }
+
+  private async onEvaluate(req: DapMessage, args: Record<string, any>): Promise<void> {
+    const expression = String(args['expression'] ?? '');
+    if (!expression || !this.gdb || this.running) {
+      this.respond(req, undefined, false, expression ? 'not stopped' : 'no expression');
+      return;
+    }
+    const frame = this.frames.get(Number(args['frameId'] ?? 0));
+    if (frame) await this.mi(`-stack-select-frame ${frame.level} --thread ${frame.threadId}`);
+    const rec = await this.mi(`-data-evaluate-expression "${expression.replace(/"/g, '\\"')}"`);
+    if (!this.miOk(rec)) {
+      this.respond(req, undefined, false, this.miError(rec));
+      return;
+    }
+    this.respond(req, { result: fieldString(rec.fields, 'value') ?? '', variablesReference: 0 });
+  }
+
+  private async onContinue(req: DapMessage): Promise<void> {
+    if (!this.gdb) {
+      this.respond(req, undefined, false, 'not started');
+      return;
+    }
+    this.running = true;
+    this.respond(req, { allThreadsContinued: true });
+    void this.mi('-exec-continue');
+  }
+
+  private async onStep(req: DapMessage, command: string): Promise<void> {
+    if (!this.gdb || this.running) {
+      this.respond(req, undefined, false, this.running ? 'already running' : 'not started');
+      return;
+    }
+    this.running = true;
+    this.respond(req, {});
+    void this.mi(command);
+  }
+
+  private async onPause(req: DapMessage): Promise<void> {
+    if (!this.gdb) {
+      this.respond(req, undefined, false, 'not started');
+      return;
+    }
+    this.respond(req, {});
+    void this.mi('-exec-interrupt');
+  }
+
+  private async onDisconnect(req: DapMessage): Promise<void> {
+    this.respond(req, {});
+    await this.shutdown();
+  }
+
+  private async shutdown(): Promise<void> {
+    if (this.exited) return;
+    this.exited = true;
+    try {
+      if (this.gdb) {
+        this.gdb.stdin?.write('-gdb-exit\n');
+        await new Promise(r => setTimeout(r, 250));
+        this.gdb.kill('SIGKILL');
+      }
+    } catch {
+      // Best effort.
+    }
+    setTimeout(() => process.exit(0), 100);
+  }
+
+  private onGdbClosed(): void {
+    this.pending.clear();
+    if (!this.exited) {
+      this.event('terminated', { restart: false });
+      void this.shutdown();
+    }
+  }
+}
+
+const adapter = new Adapter();
+adapter.run();

+ 237 - 0
extensions/pascal-debug/src/adapter/mi.ts

@@ -0,0 +1,237 @@
+/** Minimal GDB/MI output parser (plain Node, no dependencies).
+ *
+ *  Handles result records (`TOKEN^done,...`), out-of-band records
+ *  (`*stopped,...`, `=notify,...`), stream records (`~`, `&`, `@`),
+ *  the `(gdb)` prompt and anything else (treated as inferior output).
+ *  Tolerant by design: unparseable lines surface as output, never throw.
+ */
+
+export type MIFields = { [name: string]: MIValue };
+export type MIValue = string | MIFields | MIValue[];
+
+export interface MIRecord {
+  token: string;
+  /** `^` result, `*` exec-async, `+` status-async, `=` notify, `~` console, `&` log, `@` target. */
+  marker: string;
+  /** Class after the marker (`done`, `error`, `stopped`, ...) or '' for streams. */
+  cls: string;
+  /** Raw remainder after `marker + cls` (for streams: the payload). */
+  rest: string;
+  fields: MIFields;
+}
+
+export function classifyLine(line: string): MIRecord | { prompt: true } | { output: string } {
+  const trimmed = line.replace(/\r$/, '');
+  if (trimmed === '(gdb)') return { prompt: true };
+  const m = /^([0-9]*)([\^*+=~&@])(.*)$/.exec(trimmed);
+  if (!m) return { output: line };
+  const [, token, marker, rest] = m;
+  if (marker === '~' || marker === '&') {
+    return { token, marker, cls: '', rest, fields: { text: unescapeMIString(rest.trim()) } };
+  }
+  if (marker === '@') {
+    return { token, marker, cls: '', rest, fields: { text: rest } };
+  }
+  const comma = rest.indexOf(',');
+  const cls = comma < 0 ? rest : rest.slice(0, comma);
+  const body = comma < 0 ? '' : rest.slice(comma + 1);
+  let fields: MIFields = {};
+  try {
+    fields = body ? parseResultList(body) : {};
+  } catch {
+    fields = {};
+  }
+  return { token, marker, cls, rest, fields };
+}
+
+function isRecord(r: unknown): r is MIRecord {
+  return !!r && typeof r === 'object' && 'marker' in (r as object);
+}
+
+export { isRecord };
+
+/** Parse `name=value,name=value,...` at top level. */
+export function parseResultList(s: string): MIFields {
+  const out: MIFields = {};
+  const p = new Cursor(s);
+  p.skipSpaces();
+  while (!p.eof()) {
+    const name = p.readName();
+    if (name === null || !p.eat('=')) break;
+    out[name] = p.readValue();
+    p.skipSpaces();
+    if (!p.eat(',')) break;
+    p.skipSpaces();
+  }
+  return out;
+}
+
+class Cursor {
+  pos = 0;
+  constructor(public s: string) {}
+
+  eof(): boolean {
+    return this.pos >= this.s.length;
+  }
+
+  skipSpaces(): void {
+    while (this.s[this.pos] === ' ') this.pos++;
+  }
+
+  eat(c: string): boolean {
+    if (this.s[this.pos] === c) { this.pos++; return true; }
+    return false;
+  }
+
+  readName(): string | null {
+    const m = /^[A-Za-z_][A-Za-z0-9_\-]*/.exec(this.s.slice(this.pos));
+    if (!m) return null;
+    this.pos += m[0].length;
+    return m[0];
+  }
+
+  readValue(): MIValue {
+    this.skipSpaces();
+    const c = this.s[this.pos];
+    if (c === '"') return this.readCString();
+    if (c === '{') return this.readTuple();
+    if (c === '[') return this.readList();
+    return this.readConst();
+  }
+
+  readCString(): string {
+    // Opening quote already peeked.
+    this.pos++;
+    let out = '';
+    while (this.pos < this.s.length) {
+      const c = this.s[this.pos];
+      if (c === '"') { this.pos++; return out; }
+      if (c === '\\') {
+        const esc = this.s.slice(this.pos, this.pos + 4);
+        const decoded = decodeEscape(esc);
+        out += decoded.text;
+        this.pos += decoded.length;
+        continue;
+      }
+      out += c;
+      this.pos++;
+    }
+    return out;
+  }
+
+  readTuple(): MIFields {
+    this.pos++; // {
+    const out: MIFields = {};
+    this.skipSpaces();
+    if (this.eat('}')) return out;
+    for (;;) {
+      this.skipSpaces();
+      const name = this.readName();
+      if (name === null || !this.eat('=')) break;
+      out[name] = this.readValue();
+      this.skipSpaces();
+      if (this.eat(',')) continue;
+      break;
+    }
+    this.eat('}');
+    return out;
+  }
+
+  readList(): MIValue[] {
+    this.pos++; // [
+    const out: MIValue[] = [];
+    this.skipSpaces();
+    if (this.eat(']')) return out;
+    for (;;) {
+      this.skipSpaces();
+      out.push(this.readListItem());
+      this.skipSpaces();
+      if (this.eat(',')) continue;
+      break;
+    }
+    this.eat(']');
+    return out;
+  }
+
+  readListItem(): MIValue {
+    // Items are values, but result-shaped `name=value` items occur as well.
+    const save = this.pos;
+    const name = this.readName();
+    if (name !== null && this.eat('=')) {
+      const v = this.readValue();
+      const o: MIFields = {};
+      o[name] = v;
+      return o;
+    }
+    this.pos = save;
+    return this.readValue();
+  }
+
+  readConst(): string {
+    const start = this.pos;
+    while (this.pos < this.s.length && ![',', '}', ']'].includes(this.s[this.pos])) this.pos++;
+    return this.s.slice(start, this.pos);
+  }
+}
+
+function decodeEscape(esc: string): { text: string; length: number } {
+  const c = esc[1];
+  switch (c) {
+    case 'n': return { text: '\n', length: 2 };
+    case 'r': return { text: '\r', length: 2 };
+    case 't': return { text: '\t', length: 2 };
+    case 'a': return { text: '\x07', length: 2 };
+    case 'b': return { text: '\b', length: 2 };
+    case 'f': return { text: '\f', length: 2 };
+    case 'v': return { text: '\v', length: 2 };
+    case 'e': return { text: '\x1b', length: 2 };
+    case '\\': return { text: '\\', length: 2 };
+    case '"': return { text: '"', length: 2 };
+    case "'": return { text: "'", length: 2 };
+    case '0': case '1': case '2': case '3':
+    case '4': case '5': case '6': case '7': {
+      const m = /^\\([0-7]{1,3})/.exec(esc);
+      if (m) return { text: String.fromCharCode(parseInt(m[1], 8)), length: m[0].length };
+      return { text: c, length: 2 };
+    }
+    default: return { text: c ?? '', length: 2 };
+  }
+}
+
+/** Unescape the inside of an MI `"..."` stream payload. */
+export function unescapeMIString(quoted: string): string {
+  const inner = quoted.startsWith('"') && quoted.endsWith('"') && quoted.length >= 2
+    ? quoted.slice(1, -1)
+    : quoted;
+  let out = '';
+  let i = 0;
+  while (i < inner.length) {
+    if (inner[i] === '\\') {
+      const decoded = decodeEscape(inner.slice(i, i + 4));
+      out += decoded.text;
+      i += decoded.length;
+    } else {
+      out += inner[i];
+      i++;
+    }
+  }
+  return out;
+}
+
+// ---- Convenience accessors (all total: undefined on shape mismatch) ----
+
+export function asString(v: MIValue | undefined): string | undefined {
+  return typeof v === 'string' ? v : undefined;
+}
+
+export function asFields(v: MIValue | undefined): MIFields | undefined {
+  return v !== undefined && typeof v === 'object' && !Array.isArray(v) ? v as MIFields : undefined;
+}
+
+export function asList(v: MIValue | undefined): MIValue[] {
+  return Array.isArray(v) ? v : [];
+}
+
+export function fieldString(fields: MIFields, name: string): string | undefined {
+  return asString(fields[name]);
+}

+ 138 - 0
extensions/pascal-debug/src/node/debug-adapter-contribution.ts

@@ -0,0 +1,138 @@
+import { injectable } from '@theia/core/shared/inversify';
+import { IJSONSchema } from '@theia/core/lib/common/json-schema';
+import { MaybePromise } from '@theia/core/lib/common/types';
+import {
+  DebugAdapterContribution, DebugAdapterExecutable,
+} from '@theia/debug/lib/common/debug-model';
+import { DebugConfiguration } from '@theia/debug/lib/common/debug-configuration';
+import * as fs from 'fs';
+import * as path from 'path';
+
+/** Debug type contributed by the Pascal GDB adapter. */
+export const PASCAL_DEBUG_TYPE = 'pascal';
+
+@injectable()
+export class PascalDebugAdapterContribution implements DebugAdapterContribution {
+  readonly type = PASCAL_DEBUG_TYPE;
+  readonly label: MaybePromise<string | undefined> = 'Pascal (GDB)';
+  readonly languages: MaybePromise<string[] | undefined> = ['pascal'];
+
+  getSchemaAttributes(): MaybePromise<IJSONSchema[]> {
+    return [{
+      type: 'object',
+      required: ['program'],
+      properties: {
+        program: {
+          type: 'string',
+          description: 'Program to debug (absolute path or ${workspaceFolder}-relative). Build it with -g for source lines.',
+        },
+        args: {
+          type: 'array',
+          items: { type: 'string' },
+          description: 'Program arguments.',
+        },
+        cwd: {
+          type: 'string',
+          description: 'Working directory. Defaults to the workspace folder.',
+        },
+        stopOnEntry: {
+          type: 'boolean',
+          default: false,
+          description: 'Stop at program entry before running.',
+        },
+      },
+    }];
+  }
+
+  async provideDebugConfigurations(workspaceFolderUri?: string): Promise<DebugConfiguration[]> {
+    let program = '${workspaceFolder}/bin/program';
+    let cwd = '${workspaceFolder}';
+    if (workspaceFolderUri) {
+      const folder = uriToFsPath(workspaceFolderUri);
+      cwd = workspaceFolderUri;
+      program = defaultProgram(folder) ?? program;
+    }
+    return [{
+      type: PASCAL_DEBUG_TYPE,
+      name: 'Debug Pascal program',
+      request: 'launch',
+      program,
+      cwd,
+      stopOnEntry: false,
+      args: [],
+    } as DebugConfiguration];
+  }
+
+  async resolveDebugConfiguration(
+    config: DebugConfiguration, workspaceFolderUri?: string,
+  ): Promise<DebugConfiguration | undefined> {
+    if (!config.program && workspaceFolderUri) {
+      const folder = uriToFsPath(workspaceFolderUri);
+      config.program = defaultProgram(folder) ?? '${workspaceFolder}/bin/program';
+    }
+    if (!config.cwd && workspaceFolderUri) {
+      config.cwd = workspaceFolderUri;
+    }
+    return config;
+  }
+
+  provideDebugAdapterExecutable(): MaybePromise<DebugAdapterExecutable | undefined> {
+    const adapter = findAdapterModule();
+    return adapter ? { modulePath: adapter, args: [] } : undefined;
+  }
+}
+
+/** Locate the forkable debug adapter module in packaged and dev layouts.
+ *
+ *  The backend runs bundled (esbuild), so `__dirname` points at the app's
+ *  `lib/backend` and cannot be used to find extension files. Instead:
+ *  packaged apps ship the adapter under `resources/adapter-pascal` (see the
+ *  electron-builder `extraResources` entry); dev resolves through the
+ *  workspace symlink.
+ */
+export function findAdapterModule(): string | undefined {
+  const candidates: string[] = [];
+  try {
+    const resources = (process as { resourcesPath?: string }).resourcesPath;
+    if (resources) candidates.push(path.join(resources, 'adapter-pascal', 'gdb-adapter.js'));
+  } catch {
+    // No Electron runtime: fall through to module resolution.
+  }
+  try {
+    candidates.push(require.resolve('pascal-debug/lib/adapter/gdb-adapter.js'));
+  } catch {
+    // Not resolvable from here.
+  }
+  for (const candidate of candidates) {
+    try {
+      fs.accessSync(candidate, fs.constants.R_OK);
+      return candidate;
+    } catch {
+      // Try the next candidate.
+    }
+  }
+  return undefined;
+}
+
+/** Preferred program from pascal.json (bin/<name> or output), else undefined. */
+export function defaultProgram(folder: string): string | undefined {
+  try {
+    const raw = JSON.parse(fs.readFileSync(path.join(folder, 'pascal.json'), 'utf8')) as {
+      name?: unknown; output?: unknown;
+    };
+    const out = typeof raw.output === 'string' && raw.output.length > 0
+      ? raw.output
+      : (typeof raw.name === 'string' && raw.name.length > 0 ? `bin/${raw.name}` : undefined);
+    return out ? path.join(folder, out) : undefined;
+  } catch {
+    return undefined;
+  }
+}
+
+function uriToFsPath(uri: string): string {
+  try {
+    return decodeURIComponent(new URL(uri).pathname);
+  } catch {
+    return uri;
+  }
+}

+ 7 - 0
extensions/pascal-debug/src/node/pascal-debug-backend-module.ts

@@ -0,0 +1,7 @@
+import { ContainerModule } from '@theia/core/shared/inversify';
+import { DebugAdapterContribution } from '@theia/debug/lib/common/debug-model';
+import { PascalDebugAdapterContribution } from './debug-adapter-contribution';
+
+export default new ContainerModule(bind => {
+  bind(DebugAdapterContribution).to(PascalDebugAdapterContribution).inSingletonScope();
+});

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

+ 11 - 0
extensions/pascal-workbench/package.json

@@ -0,0 +1,11 @@
+{
+  "name": "pascal-workbench",
+  "displayName": "Pascal Workbench",
+  "version": "0.1.0",
+  "keywords": ["theia-extension"],
+  "files": ["lib", "src"],
+  "dependencies": {"@theia/core": "1.75.0", "@theia/monaco": "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/pascal-workbench-frontend-module"}]
+}

+ 62 - 0
extensions/pascal-workbench/src/browser/pascal-semantic-colors.ts

@@ -0,0 +1,62 @@
+/** Pure semantic-token color support (no Theia/monaco imports, testable headlessly). */
+
+/** Semantic token colors by theme brightness (VS Code conventions). */
+export function semanticTokenRules(dark: boolean): Record<string, string> {
+  if (dark) {
+    return {
+      variable: '#9CDCFE',
+      parameter: '#9CDCFE',
+      property: '#9CDCFE',
+      function: '#DCDCAA',
+      type: '#4EC9B0',
+      namespace: '#4EC9B0',
+      enumMember: '#4EC9B0',
+    };
+  }
+  return {
+    variable: '#001080',
+    parameter: '#001080',
+    property: '#001080',
+    function: '#795E26',
+    type: '#267F99',
+    namespace: '#267F99',
+    enumMember: '#267F99',
+  };
+}
+
+export interface TokenRuleLike {
+  token: string;
+  foreground?: string;
+  [key: string]: unknown;
+}
+
+export interface ThemeDataLike {
+  semanticHighlighting?: boolean;
+  semanticTokenColors?: Record<string, string>;
+  rules?: TokenRuleLike[];
+  [key: string]: unknown;
+}
+
+/** Add semantic highlighting to theme data without touching anything else.
+ *
+ *  The bundled Theia monaco build hardcodes `StandaloneTheme.semanticHighlighting`
+ *  to false and styles semantic tokens through the TextMate `rules` (see
+ *  `standaloneThemeService.js` / `semanticTokensProviderStyling.js`), so the
+ *  `semanticTokenColors` map alone is inert there. We therefore add one `rules`
+ *  entry per semantic type as well (keeping `semanticTokenColors` for builds
+ *  that do honor it). Existing entries always win. Idempotent.
+ */
+export function withSemanticColors<T extends ThemeDataLike>(data: T, dark: boolean): T {
+  const wanted = semanticTokenRules(dark);
+  const existing = Array.isArray(data.rules) ? data.rules : [];
+  const have = new Set(existing.map(r => r.token));
+  const added = Object.entries(wanted)
+    .filter(([token]) => !have.has(token))
+    .map(([token, foreground]) => ({ token, foreground }));
+  return {
+    ...data,
+    semanticHighlighting: true,
+    semanticTokenColors: { ...wanted, ...(data.semanticTokenColors ?? {}) },
+    rules: [...existing, ...added],
+  };
+}

+ 74 - 0
extensions/pascal-workbench/src/browser/pascal-semantic-style.ts

@@ -0,0 +1,74 @@
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
+import { ThemeService } from '@theia/core/lib/browser/theming';
+import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service';
+import { PreferenceScope } from '@theia/core/lib/common/preferences/preference-scope';
+import { MonacoThemeRegistry } from '@theia/monaco/lib/browser/textmate/monaco-theme-registry';
+import type { ThemeMix } from '@theia/monaco/lib/browser/textmate/monaco-theme-types';
+import { withSemanticColors, ThemeDataLike } from './pascal-semantic-colors';
+
+/** Bundled monaco theme names patched with semantic colors (best-effort). */
+const MONACO_THEMES = ['dark-theia', 'light-theia', 'hc-theia', 'hc-theia-light'];
+
+/** Preference gate monaco checks before even fetching semantic tokens. */
+const SEMANTIC_PREF = 'editor.semanticHighlighting.enabled';
+
+/** Enables semantic highlighting in the bundled editor themes.
+ *
+ *  Stock Theia themes ship no rules for our token types, so semantic tokens
+ *  would fall back to TextMate colors even when fetched — and worse, the
+ *  bundled monaco hardcodes `StandaloneTheme.semanticHighlighting` to false,
+ *  so with the default `configuredByTheme` setting tokens are never fetched
+ *  at all. This (a) patches the registered monaco themes in place with one
+ *  additive `rules` entry per token type, and (b) defaults the preference to
+ *  `true` unless the user configured any value at any scope. Monaco refreshes
+ *  open editors automatically.
+ */
+@injectable()
+export class PascalSemanticStyling implements FrontendApplicationContribution {
+  @inject(MonacoThemeRegistry) protected readonly themes: MonacoThemeRegistry;
+  @inject(ThemeService) protected readonly themeService: ThemeService;
+  @inject(PreferenceService) protected readonly prefs: PreferenceService;
+
+  onStart(): void {
+    this.ensureEnabled();
+    this.patchAll();
+    this.themeService.onDidColorThemeChange(() => this.patchAll());
+  }
+
+  /** Default the fetch gate to on, without overriding an explicit user choice. */
+  protected ensureEnabled(): void {
+    try {
+      const inspection = this.prefs.inspect<boolean>(SEMANTIC_PREF);
+      if (!inspection) return;
+      const configured = inspection.globalValue ?? inspection.workspaceValue
+        ?? inspection.workspaceFolderValue ?? inspection.sessionValue;
+      if (typeof configured === 'undefined') {
+        this.prefs.set(SEMANTIC_PREF, true, PreferenceScope.User).catch(error => {
+          console.warn('Pascal: could not enable semantic highlighting:', error);
+        });
+      }
+    } catch (error) {
+      console.warn('Pascal: could not inspect semantic highlighting preference:', error);
+    }
+  }
+
+  protected patchAll(): void {
+    const done: string[] = [];
+    for (const name of MONACO_THEMES) {
+      try {
+        const theme = this.themes.getTheme(name);
+        const data = this.themes.getThemeData(name);
+        if (!theme || !data) continue;
+        const base = (theme as unknown as { base?: string }).base;
+        const dark = base === 'vs-dark' || base === 'hc-black';
+        this.themes.setTheme(name, withSemanticColors(data as unknown as ThemeDataLike, dark) as unknown as ThemeMix);
+        done.push(name + ':' + base);
+      } catch (error) {
+        console.warn(`Pascal: could not patch semantic colors into '${name}':`, error);
+      }
+    }
+    // eslint-disable-next-line no-console
+    console.info(`Pascal: semantic colors applied to ${done.length} editor theme(s): ${done.join(', ')}`);
+  }
+}

+ 44 - 0
extensions/pascal-workbench/src/browser/pascal-theme-commands.ts

@@ -0,0 +1,44 @@
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common';
+import { ThemeService } from '@theia/core/lib/browser/theming';
+import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
+
+export const SwitchThemeCommand: Command = Command.toLocalizedCommand({
+  id: 'pascal.switchTheme', label: 'Switch Color Theme', category: 'Pascal',
+});
+
+export const ToggleThemeCommand: Command = Command.toLocalizedCommand({
+  id: 'pascal.toggleTheme', label: 'Toggle Light/Dark Theme', category: 'Pascal',
+});
+
+@injectable()
+export class PascalThemeCommands implements CommandContribution {
+  @inject(ThemeService) protected readonly themeService: ThemeService;
+  @inject(QuickInputService) protected readonly quickInput: QuickInputService;
+
+  registerCommands(commands: CommandRegistry): void {
+    commands.registerCommand(SwitchThemeCommand, { execute: () => this.switchTheme() });
+    commands.registerCommand(ToggleThemeCommand, { execute: () => this.toggleTheme() });
+  }
+
+  protected async switchTheme(): Promise<void> {
+    const current = this.themeService.getCurrentTheme();
+    const items = this.themeService.getThemes().map(theme => ({
+      label: theme.label,
+      description: theme.id === current.id ? 'current' : theme.type,
+      value: theme.id,
+    }));
+    const picked = await this.quickInput.showQuickPick(items, { placeholder: 'Select a color theme' });
+    if (picked) {
+      this.themeService.setCurrentTheme(picked.value);
+    }
+  }
+
+  protected toggleTheme(): void {
+    const current = this.themeService.getCurrentTheme();
+    const isLight = current.type === 'light' || current.type === 'hcLight';
+    const target = this.themeService.getThemes().find(theme => isLight ? theme.type === 'dark' : theme.type === 'light')
+      ?? this.themeService.defaultTheme;
+    this.themeService.setCurrentTheme(target.id);
+  }
+}

+ 44 - 0
extensions/pascal-workbench/src/browser/pascal-ui-font-contribution.ts

@@ -0,0 +1,44 @@
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
+import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service';
+import { UI_FONT_SIZE } from './pascal-ui-preferences';
+
+const STYLE_ID = 'pascal-ui-font-size';
+const DEFAULT_SIZE = 13;
+
+@injectable()
+export class PascalUIFontContribution implements FrontendApplicationContribution {
+  @inject(PreferenceService) protected readonly prefs: PreferenceService;
+
+  onStart(): void {
+    this.apply(this.currentSize());
+    this.prefs.onPreferenceChanged(event => {
+      if (event.preferenceName === UI_FONT_SIZE) {
+        this.apply(this.currentSize());
+      }
+    });
+  }
+
+  protected currentSize(): number {
+    const value = this.prefs.get<number>(UI_FONT_SIZE);
+    if (typeof value !== 'number' || !Number.isFinite(value)) {
+      return DEFAULT_SIZE;
+    }
+    return Math.min(24, Math.max(10, Math.round(value)));
+  }
+
+  protected apply(px: number): void {
+    let element = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
+    if (!element) {
+      element = document.createElement('style');
+      element.id = STYLE_ID;
+      document.head.appendChild(element);
+    }
+    element.textContent = [
+      `.p-MenuBar, .p-Menu, .lm-MenuBar, .lm-Menu { font-size: ${px}px !important; }`,
+      '.theia-notifications-container, .theia-notification-center,',
+      '.theia-notification-list-item, .theia-notification-message,',
+      `.theia-message, .theia-toast { font-size: ${px}px !important; }`,
+    ].join('\n');
+  }
+}

+ 16 - 0
extensions/pascal-workbench/src/browser/pascal-ui-preferences.ts

@@ -0,0 +1,16 @@
+import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
+
+export const UI_FONT_SIZE = 'pascal.ui.interfaceFontSize';
+
+export const pascalUIPreferenceSchema: PreferenceSchema = {
+  title: 'Pascal',
+  properties: {
+    [UI_FONT_SIZE]: {
+      type: 'number',
+      default: 13,
+      minimum: 10,
+      maximum: 24,
+      description: 'Font size in pixels for IDE menus, context menus and message notifications.',
+    },
+  },
+};

+ 41 - 0
extensions/pascal-workbench/src/browser/pascal-workbench-frontend-module.ts

@@ -0,0 +1,41 @@
+import { ContainerModule } from '@theia/core/shared/inversify';
+import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common';
+import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
+import { PreferenceContribution } from '@theia/core/lib/common/preferences/preference-schema';
+import { pascalUIPreferenceSchema } from './pascal-ui-preferences';
+import { PascalThemeCommands, SwitchThemeCommand, ToggleThemeCommand } from './pascal-theme-commands';
+import { PascalUIFontContribution } from './pascal-ui-font-contribution';
+import { PascalSemanticStyling } from './pascal-semantic-style';
+
+const category = 'Pascal';
+
+const Commands = {
+  about: Command.toLocalizedCommand({ id: 'pascal.about', label: 'About Pascal IDE', category }),
+};
+
+class CommandsContribution implements CommandContribution {
+  registerCommands(commands: CommandRegistry): void {
+    commands.registerCommand(Commands.about, {
+      execute: () => window.alert('Pascal IDE\n\nFree Pascal workbench for Eclipse Theia.')
+    });
+  }
+}
+
+class MenusContribution implements MenuContribution {
+  registerMenus(menus: MenuModelRegistry): void {
+    const submenu = ['menubar', 'pascal'];
+    menus.registerSubmenu(submenu, 'Pascal');
+    menus.registerMenuAction(submenu, { commandId: Commands.about.id });
+    menus.registerMenuAction(submenu, { commandId: SwitchThemeCommand.id });
+    menus.registerMenuAction(submenu, { commandId: ToggleThemeCommand.id });
+  }
+}
+
+export default new ContainerModule(bind => {
+  bind(PreferenceContribution).toConstantValue({ schema: pascalUIPreferenceSchema });
+  bind(CommandContribution).to(CommandsContribution).inSingletonScope();
+  bind(CommandContribution).to(PascalThemeCommands).inSingletonScope();
+  bind(FrontendApplicationContribution).to(PascalUIFontContribution).inSingletonScope();
+  bind(FrontendApplicationContribution).to(PascalSemanticStyling).inSingletonScope();
+  bind(MenuContribution).to(MenusContribution).inSingletonScope();
+});

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

+ 9 - 3
package.json

@@ -16,11 +16,14 @@
     "extensions/modula2-project",
     "extensions/modula2-workbench",
     "extensions/pascal-language",
-    "extensions/oberon-language"
+    "extensions/oberon-language",
+    "extensions/pascal-compiler",
+    "extensions/pascal-debug",
+    "extensions/pascal-workbench"
   ],
   "scripts": {
     "prepare": "yarn --cwd extensions/modula2-compiler prepare && yarn --cwd extensions/modula2-debug 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:debug && yarn build:project && yarn build:workbench && yarn build:browser",
+    "build": "yarn build:language && yarn build:compiler && yarn build:pascal-compiler && yarn build:debug && yarn build:pascal-debug && yarn build:project && yarn build:workbench && yarn build:pascal-workbench && yarn build:browser",
     "build:language": "yarn --cwd extensions/modula2-language build && yarn --cwd extensions/pascal-language build && yarn --cwd extensions/oberon-language build && bash scripts/sync-language-plugin.sh",
     "build:compiler": "yarn --cwd extensions/modula2-compiler build",
     "build:debug": "yarn --cwd extensions/modula2-debug build",
@@ -31,7 +34,10 @@
     "start:browser": "yarn --cwd apps/browser start",
     "start:electron": "yarn --cwd apps/electron start",
     "watch:language": "yarn --cwd extensions/modula2-language watch",
-    "watch:workbench": "yarn --cwd extensions/modula2-workbench watch"
+    "watch:workbench": "yarn --cwd extensions/modula2-workbench watch",
+    "build:pascal-compiler": "yarn --cwd extensions/pascal-compiler build",
+    "build:pascal-debug": "yarn --cwd extensions/pascal-debug build",
+    "build:pascal-workbench": "yarn --cwd extensions/pascal-workbench build"
   },
   "devDependencies": {
     "electron": "^42.8.1",