Bläddra i källkod

Oberon LSP: diagnostics, settings, snippets, schema (O3)

Eric Streit 3 dagar sedan
förälder
incheckning
c61179715f

+ 55 - 0
extensions/oberon-language/oberon-schema.json

@@ -0,0 +1,55 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "Oberon project",
+  "description": "Project description consumed by the Oberon IDE (build, run, tasks, wizards).",
+  "type": "object",
+  "required": ["name", "main"],
+  "properties": {
+    "name": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Project name. Defaults the built executable to bin/<name>."
+    },
+    "main": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Main file relative to the project root, e.g. \"src/Main.Mod\"."
+    },
+    "output": {
+      "type": "string",
+      "description": "Built executable relative to the root. Defaults to bin/<name>."
+    },
+    "compiler": {
+      "type": "object",
+      "description": "Compiler selection: voc (Oberon-2) or the Ulm Oberon compiler.",
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["voc", "ulm"],
+          "description": "Compiler family."
+        },
+        "path": {
+          "type": "string",
+          "description": "Compiler executable. Defaults to \"voc\" (or \"ulmo\" for that family, or the oberon.compiler.path preference)."
+        },
+        "options": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "Extra compiler options appended to the build command."
+        }
+      },
+      "additionalProperties": false
+    },
+    "sourceDirectories": {
+      "type": "array",
+      "items": { "type": "string" },
+      "description": "Source folders searched by the New Module wizard. Defaults to [\"src\"]."
+    },
+    "libraryDirectories": {
+      "type": "array",
+      "items": { "type": "string" },
+      "description": "Third-party library folders searched by language services after the source folders."
+    }
+  },
+  "additionalProperties": false
+}

+ 79 - 1
extensions/oberon-language/package.json

@@ -35,6 +35,65 @@
     "configuration": {
       "title": "Oberon",
       "properties": {
+        "modula2.oberon.uppercaseKeywords": {
+          "type": "boolean",
+          "default": true,
+          "description": "Uppercase an Oberon keyword when the completed token is followed by a non-identifier character."
+        },
+        "modula2.oberon.format.indentSize": {
+          "type": "number",
+          "default": 2,
+          "description": "Indentation width applied by the Oberon formatter."
+        },
+        "modula2.oberon.format.useTabs": {
+          "type": "boolean",
+          "default": false,
+          "description": "Indent with tabs instead of spaces."
+        },
+        "modula2.oberon.format.keywordCase": {
+          "type": "string",
+          "enum": [
+            "upper",
+            "preserve"
+          ],
+          "default": "upper",
+          "description": "Keyword case applied by the formatter. Reserved words written lowercase are uppercased; identifiers that merely look like keywords are left alone."
+        },
+        "modula2.oberon.format.spaceAroundOperators": {
+          "type": "boolean",
+          "default": true,
+          "description": "Spaces around operators (:=, +, -, ...)."
+        },
+        "modula2.oberon.format.spaceAfterComma": {
+          "type": "boolean",
+          "default": true,
+          "description": "Space after commas."
+        },
+        "modula2.oberon.format.spaceBeforeColon": {
+          "type": "boolean",
+          "default": false,
+          "description": "Space before colons in declarations."
+        },
+        "modula2.oberon.format.spaceAroundRange": {
+          "type": "boolean",
+          "default": true,
+          "description": "Spaces around the .. range operator."
+        },
+        "modula2.oberon.format.emptyLineLimit": {
+          "type": "number",
+          "default": 1,
+          "description": "Maximum consecutive blank lines kept."
+        },
+        "modula2.oberon.format.trimTrailingWhitespace": {
+          "type": "boolean",
+          "default": true,
+          "description": "Remove trailing whitespace."
+        },
+        "modula2.oberon.format.insertFinalNewline": {
+          "type": "boolean",
+          "default": true,
+          "description": "End the file with a newline."
+        },
         "modula2.oberon.dialect": {
           "type": "string",
           "enum": [
@@ -51,7 +110,26 @@
           "description": "Validator command per Oberon dialect, e.g. {\"oberon2\": \"/path/to/O2\"}. Each command receives the temporary source file as its single argument."
         }
       }
-    }
+    },
+    "grammars": [
+      {
+        "language": "oberon",
+        "scopeName": "source.oberon",
+        "path": "./syntaxes/oberon.tmLanguage.json"
+      }
+    ],
+    "snippets": [
+      {
+        "language": "oberon",
+        "path": "./snippets/oberon.json"
+      }
+    ],
+    "jsonValidation": [
+      {
+        "fileMatch": "oberon.json",
+        "url": "./oberon-schema.json"
+      }
+    ]
   },
   "scripts": {
     "clean": "rimraf out",

+ 37 - 0
extensions/oberon-language/snippets/oberon.json

@@ -0,0 +1,37 @@
+{
+  "module": {
+    "prefix": "module",
+    "body": ["MODULE ${1:ModuleName};", "", "IMPORT ${2:Out};", "", "${3}", "", "BEGIN", "    ${4}", "END ${1:ModuleName}."],
+    "description": "Create an Oberon module"
+  },
+  "procedure": {
+    "prefix": "proc",
+    "body": ["PROCEDURE ${1:ProcedureName}${2}*(${3});", "BEGIN", "    ${4}", "END ${1:ProcedureName};"],
+    "description": "Create an Oberon procedure"
+  },
+  "function": {
+    "prefix": "func",
+    "body": ["PROCEDURE ${1:FunctionName}${2}*(${3}): ${4:INTEGER};", "BEGIN", "    ${5}", "    RETURN ${6:0}", "END ${1:FunctionName};"],
+    "description": "Create an Oberon function procedure"
+  },
+  "if": {
+    "prefix": "if",
+    "body": ["IF ${1:condition} THEN", "    ${2}", "END;"],
+    "description": "Create an IF statement"
+  },
+  "while": {
+    "prefix": "while",
+    "body": ["WHILE ${1:condition} DO", "    ${2}", "END;"],
+    "description": "Create a WHILE loop"
+  },
+  "for": {
+    "prefix": "for",
+    "body": ["FOR ${1:i} := ${2:0} TO ${3:n} DO", "    ${4}", "END;"],
+    "description": "Create a FOR loop"
+  },
+  "record": {
+    "prefix": "record",
+    "body": ["${1:T} = RECORD", "    ${2:field}: ${3:INTEGER};", "END;"],
+    "description": "Create an Oberon record type"
+  }
+}

+ 143 - 0
extensions/oberon-language/src/diagnostics.ts

@@ -0,0 +1,143 @@
+import * as vscode from 'vscode';
+
+const MODULE_HEADER = /\b(DEFINITION\s+)?MODULE\s+([A-Za-z_][A-Za-z0-9_]*)/;
+const END_MODULE = /\bEND\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\.)?/g;
+
+interface ScanResult {
+  diagnostics: vscode.Diagnostic[];
+  code: string;
+}
+
+function positionAt(document: vscode.TextDocument, offset: number): vscode.Position {
+  return document.positionAt(Math.max(0, Math.min(offset, document.getText().length)));
+}
+
+function rangeAt(document: vscode.TextDocument, start: number, end: number): vscode.Range {
+  return new vscode.Range(positionAt(document, start), positionAt(document, end));
+}
+
+function diagnostic(
+  document: vscode.TextDocument,
+  start: number,
+  end: number,
+  message: string,
+  severity = vscode.DiagnosticSeverity.Error
+): vscode.Diagnostic {
+  const item = new vscode.Diagnostic(rangeAt(document, start, end), message, severity);
+  item.source = 'Oberon';
+  return item;
+}
+
+/**
+ * Lightweight diagnostics used alongside the Coco/R dialect validators.
+ *
+ * Only constructs recognisable without pretending to be a complete
+ * Oberon parser: unterminated comments/strings, the module header, and
+ * the final `END name.` mismatch. Note Oberon is case-sensitive, so the
+ * header match is exact. Real syntax diagnostics come from the
+ * configured dialect validator.
+ */
+function scan(document: vscode.TextDocument): ScanResult {
+  const text = document.getText();
+  const diagnostics: vscode.Diagnostic[] = [];
+  let code = '';
+  let i = 0;
+
+  while (i < text.length) {
+    const ch = text[i];
+    const next = text[i + 1];
+
+    if (ch === '(' && next === '*') {
+      const start = i;
+      i += 2;
+      let depth = 1;
+      while (i < text.length && depth > 0) {
+        if (text[i] === '(' && text[i + 1] === '*') { depth++; i += 2; continue; }
+        if (text[i] === '*' && text[i + 1] === ')') { depth--; i += 2; continue; }
+        i++;
+      }
+      if (depth > 0) {
+        diagnostics.push(diagnostic(document, start, Math.min(start + 2, text.length), 'Unterminated Oberon comment.'));
+      }
+      code += '  ';
+      continue;
+    }
+
+    if (ch === '"' || ch === "'") {
+      const start = i;
+      i++;
+      let closed = false;
+      while (i < text.length && text[i] !== '\n') {
+        if (text[i] === ch) { i++; closed = true; break; }
+        i++;
+      }
+      if (!closed) {
+        diagnostics.push(diagnostic(document, start, Math.min(start + 1, text.length), 'Unterminated string literal.'));
+      }
+      code += '  ';
+      continue;
+    }
+
+    code += ch;
+    i++;
+  }
+
+  const header = MODULE_HEADER.exec(code);
+  if (!header) {
+    diagnostics.push(diagnostic(document, 0, Math.min(code.length, 1), 'Expected a MODULE header.'));
+    return { diagnostics, code };
+  }
+
+  const moduleName = header[2];
+  const headerEnd = header.index + header[0].length;
+  const semicolon = code.indexOf(';', headerEnd);
+  if (semicolon < 0) {
+    diagnostics.push(diagnostic(document, headerEnd, Math.min(headerEnd + moduleName.length, code.length), 'Expected ";" after the module header.'));
+  }
+
+  END_MODULE.lastIndex = 0;
+  let match: RegExpExecArray | null = null;
+  let lastEnd: RegExpExecArray | null = null;
+  while ((match = END_MODULE.exec(code)) !== null) {
+    lastEnd = match;
+  }
+
+  if (!lastEnd) {
+    diagnostics.push(diagnostic(document, Math.max(0, code.trimEnd().length - 1), code.length, `Expected "END ${moduleName}." at the end of the module.`));
+  } else {
+    const endName = lastEnd[1];
+    const nameStart = lastEnd.index + lastEnd[0].indexOf(endName);
+    // Case-sensitive: END Name must match MODULE Name exactly.
+    if (endName !== moduleName) {
+      diagnostics.push(diagnostic(document, nameStart, nameStart + endName.length, `Module name "${moduleName}" expected after END, found "${endName}".`));
+    }
+    if (!lastEnd[2]) {
+      const dot = lastEnd.index + lastEnd[0].length;
+      diagnostics.push(diagnostic(document, dot, Math.min(dot + 1, code.length), `Expected "." after END ${endName}.`));
+    }
+  }
+
+  return { diagnostics, code };
+}
+
+export function createDiagnostics(context: vscode.ExtensionContext): void {
+  const collection = vscode.languages.createDiagnosticCollection('oberon');
+  context.subscriptions.push(collection);
+
+  const update = (document: vscode.TextDocument): void => {
+    if (document.languageId !== 'oberon') {
+      return;
+    }
+    collection.set(document.uri, scan(document).diagnostics);
+  };
+
+  for (const document of vscode.workspace.textDocuments) {
+    update(document);
+  }
+
+  context.subscriptions.push(
+    vscode.workspace.onDidOpenTextDocument(update),
+    vscode.workspace.onDidChangeTextDocument(event => update(event.document)),
+    vscode.workspace.onDidCloseTextDocument(document => collection.delete(document.uri))
+  );
+}

+ 79 - 1
extensions/oberon-language/src/extension.ts

@@ -1,7 +1,23 @@
 import * as vscode from 'vscode';
+import { createDiagnostics } from './diagnostics';
 import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node';
 
+const KEYWORDS = new Set([
+  'ARRAY', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV', 'DO',
+  'ELSE', 'ELSIF', 'END', 'EXIT', 'FOR', 'IF', 'IMPORT', 'IN', 'IS',
+  'LOOP', 'MOD', 'MODULE', 'NIL', 'OF', 'OR', 'POINTER', 'PROCEDURE',
+  'RECORD', 'REPEAT', 'RETURN', 'THEN', 'TO', 'TYPE', 'UNTIL', 'VAR',
+  'WHILE', 'WITH'
+]);
+
+let applyingEdit = false;
+
+function isIdentifierCharacter(ch: string | undefined): boolean {
+  return !!ch && /[A-Za-z0-9_]/.test(ch);
+}
+
 export function activate(context: vscode.ExtensionContext): void {
+  createDiagnostics(context);
   const serverModule = context.asAbsolutePath('out/server.js');
   const serverOptions: ServerOptions = {
     run: { module: serverModule, transport: TransportKind.ipc },
@@ -15,12 +31,74 @@ export function activate(context: vscode.ExtensionContext): void {
     synchronize: { configurationSection: 'modula2' },
     initializationOptions: {
       oberonDialect: cfg.get<string>('oberon.dialect', 'oberon2'),
-      oberonValidators: cfg.get<object>('oberon.validators', {})
+      oberonValidators: cfg.get<object>('oberon.validators', {}),
+      format: {
+        indentSize: cfg.get<number>('oberon.format.indentSize', 2),
+        useTabs: cfg.get<boolean>('oberon.format.useTabs', false),
+        keywordCase: cfg.get<string>('oberon.format.keywordCase', 'upper'),
+        spaceAroundOperators: cfg.get<boolean>('oberon.format.spaceAroundOperators', true),
+        spaceAfterComma: cfg.get<boolean>('oberon.format.spaceAfterComma', true),
+        spaceBeforeColon: cfg.get<boolean>('oberon.format.spaceBeforeColon', false),
+        spaceAroundRange: cfg.get<boolean>('oberon.format.spaceAroundRange', true),
+        emptyLineLimit: cfg.get<number>('oberon.format.emptyLineLimit', 1),
+        trimTrailingWhitespace: cfg.get<boolean>('oberon.format.trimTrailingWhitespace', true),
+        insertFinalNewline: cfg.get<boolean>('oberon.format.insertFinalNewline', true)
+      }
     }
   };
   const client = new LanguageClient('oberonCocoR', 'Oberon Coco/R Language Server', serverOptions, clientOptions);
   context.subscriptions.push(client);
   void client.start();
+  context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(async event => {
+    if (applyingEdit || event.document.languageId !== 'oberon' ||
+        !vscode.workspace.getConfiguration('modula2').get<boolean>('oberon.uppercaseKeywords', true)) {
+      return;
+    }
+
+    const editor = vscode.window.activeTextEditor;
+    if (!editor || editor.document.uri.toString() !== event.document.uri.toString()) {
+      return;
+    }
+
+    for (const change of event.contentChanges) {
+      const position = change.range.start.translate(0, change.text.length);
+      const line = event.document.lineAt(position.line).text;
+      let start = position.character;
+
+      while (start > 0 && isIdentifierCharacter(line[start - 1])) {
+        start--;
+      }
+
+      let end = position.character;
+      while (end < line.length && isIdentifierCharacter(line[end])) {
+        end++;
+      }
+
+      // Do not modify a token while it is still being typed.
+      if (end >= line.length || isIdentifierCharacter(line[end])) {
+        continue;
+      }
+
+      const token = line.slice(start, end);
+      const upper = token.toUpperCase();
+
+      if (!KEYWORDS.has(upper) || token === upper) {
+        continue;
+      }
+
+      applyingEdit = true;
+      try {
+        await editor.edit(builder => {
+          builder.replace(
+            new vscode.Range(position.line, start, position.line, end),
+            upper
+          );
+        });
+      } finally {
+        applyingEdit = false;
+      }
+    }
+  }));
 }
 
 export function deactivate(): void {}