From 8af6299db8479ded0ad91ac43000b85492e654e9 Mon Sep 17 00:00:00 2001 From: Marcello Urbani Date: Sun, 17 Nov 2024 12:36:34 +0000 Subject: [PATCH] Bitbucket normalizer (#168) Co-authored-by: Lars Hvam --- browser.webpack.config.js | 35 +++++---- client/package.json | 2 +- client/src/extension.ts | 14 +++- client/src/integrations.ts | 83 ++++++++++++++++++++ client/src/normalize.ts | 58 ++++++++++++++ package.json | 28 +++++++ server/src/codenormalizer.ts | 147 +++++++++++++++++++++++++++++++++++ server/src/server.ts | 11 +++ 8 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 client/src/integrations.ts create mode 100644 client/src/normalize.ts create mode 100644 server/src/codenormalizer.ts diff --git a/browser.webpack.config.js b/browser.webpack.config.js index 223ab2b6..52e1a500 100644 --- a/browser.webpack.config.js +++ b/browser.webpack.config.js @@ -23,15 +23,17 @@ const browserClientConfig = /** @type WebpackConfig */ { extensions: ['.ts', '.js'], // support ts-files and js-files alias: {}, fallback: { - "fs": false, - "path": require.resolve("path-browserify") - }, + "fs": false, + "path": require.resolve("path-browserify"), + "crypto": require.resolve("crypto-browserify"), + "vm": require.resolve("vm-browserify") + } }, - plugins: [ - new ProvidePlugin({ - Buffer: [require.resolve("buffer/"), "Buffer"], - }), - ], + plugins: [ + new ProvidePlugin({ + Buffer: [require.resolve("buffer/"), "Buffer"], + }), + ], module: { rules: [ { @@ -71,16 +73,17 @@ const browserServerConfig = /** @type WebpackConfig */ { mainFields: ['module', 'main'], extensions: ['.ts', '.js'], // support ts-files and js-files alias: { - glob: false, - }, + glob: false, + }, fallback: { "path": require.resolve("path-browserify"), - "crypto": require.resolve("crypto-browserify"), - util: false, - fs: false, - child_process: false, - os: false, - assert: false + "crypto": require.resolve("crypto-browserify"), + "vm": require.resolve("vm-browserify"), + util: false, + fs: false, + child_process: false, + os: false, + assert: false }, }, module: { diff --git a/client/package.json b/client/package.json index 17905ebc..6c1bd7b0 100644 --- a/client/package.json +++ b/client/package.json @@ -23,4 +23,4 @@ "@types/vscode": "^1.91.0", "buffer": "^6.0.3" } -} +} \ No newline at end of file diff --git a/client/src/extension.ts b/client/src/extension.ts index 707602c0..38d5db99 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -9,6 +9,8 @@ import {Help} from "./help"; import {Config} from "./config"; import {Flows} from "./flows"; import {TestController} from "./test_controller"; +import {registerBitbucket} from "./integrations"; +import {registerNormalizer} from "./normalize"; let client: BaseLanguageClient; let myStatusBarItem: vscode.StatusBarItem; @@ -16,6 +18,7 @@ let highlight: Highlight; let help: Help; let flows: Flows; let config: Config; +let disposeAll:()=>void|undefined; function registerAsFsProvider(client: BaseLanguageClient) { const toUri = (path: string) => Uri.file(path); @@ -43,6 +46,7 @@ function registerAsFsProvider(client: BaseLanguageClient) { } export function activate(context: ExtensionContext) { + disposeAll = () => context.subscriptions.forEach(async d => d.dispose()); myStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); myStatusBarItem.text = "abaplint"; myStatusBarItem.show(); @@ -122,14 +126,16 @@ export function activate(context: ExtensionContext) { highlight.highlightWritesResponse(data.ranges, data.uri); }); }); - -// removed, TODO: what was this used for? -// context.subscriptions.push(await client.start()); + registerNormalizer(context, client); + registerBitbucket(client); } export function deactivate(): Thenable | undefined { if (!client) { return undefined; } - return client.stop(); + const stop = client.stop().then(() => client.dispose()); + if (disposeAll) {disposeAll();} + return stop; } + diff --git a/client/src/integrations.ts b/client/src/integrations.ts new file mode 100644 index 00000000..dbcefe4e --- /dev/null +++ b/client/src/integrations.ts @@ -0,0 +1,83 @@ +import {extensions, Uri, workspace} from "vscode"; +import {BaseLanguageClient} from "vscode-languageclient"; + +export const ATLASCODEDIFF = "atlascode.bbpr"; +export interface CodeNormalizer { + isRelevant: (u: Uri) => boolean; + normalize: (code: string, uri: Uri) => Promise; +} + +export let integrationIsActive:(u:Uri) => boolean = () => false; +interface BitBucketApi { + registerCodeNormalizer:(n:CodeNormalizer)=>Disposable; +} + +const shouldNormalize = (u:Uri) => { + try { + const o = JSON.parse(u.query); + return !! o.normalized; + } catch (error) { + return false; + } +}; + +const extractname = (u:Uri) => { + if (u.scheme !== ATLASCODEDIFF) {return u.path;} + try { + const details = JSON.parse(u.query); + if (details.path && typeof details.path === "string") { + return details.path; + } + } catch (error) { + return u.fragment; + } +}; +const normalizations = ["On by default", "Off by default", "deactivated"] as const; +type Normalization = (typeof normalizations[I]); +const getNormalization = (): Normalization => { + const n = workspace.getConfiguration("abaplint").get("codeNormalization") as any; + if (normalizations.includes(n)) {return n;} + return "Off by default"; +}; +const isAbap = (u:Uri) => !!(u.fsPath.match(/\.abap$/) || u.fragment.match(/\.abap$/)); +export const getAbapCodeNormalizer = (client: BaseLanguageClient):CodeNormalizer => { + const normalization = getNormalization(); + const inverted = normalization === "On by default"; + const inactive = normalization === "deactivated"; + return { + isRelevant:(u) => !inactive && isAbap(u), + normalize:async (source, uri) => { + if (inactive || !isAbap(uri) || (inverted === shouldNormalize(uri))) { + return source; + } + const path = extractname(uri); + try { + const formatted:string = await client.sendRequest("abaplint/normalize", {path, source}); + return formatted; + } catch (error) { + return source; + } + }, + }; +}; + +// registers a code formatter for bitbucket using an API which will probably never be merged +// for now it's available on my fork of atlascode: +// https://bitbucket.org/marcellourbani/atlascode/branch/issue-%235433-Add-hook-to-pretty-print-code-to-show-in-diff-in-atlascode +// allows to: +// - normalize the code by default +// - get bitbucket functionality (i.e. comments) to work after normalizing +export const registerBitbucket = async (client: BaseLanguageClient) => { + const ext = extensions.getExtension("atlassian.atlascode"); + if (!ext) { + return; + } + if (!ext.isActive) { + await ext.activate(); + } + if (ext.exports?.registerCodeNormalizer) { + const norm = getAbapCodeNormalizer(client); + integrationIsActive = (u) => u.scheme === ATLASCODEDIFF && norm.isRelevant(u); + ext.exports.registerCodeNormalizer(norm); + } +}; diff --git a/client/src/normalize.ts b/client/src/normalize.ts new file mode 100644 index 00000000..e7ae614b --- /dev/null +++ b/client/src/normalize.ts @@ -0,0 +1,58 @@ +import {commands, ExtensionContext, TabInputTextDiff, TextEditor, Uri, window, TextDocumentContentProvider, Event, workspace} from "vscode"; +import {BaseLanguageClient} from "vscode-languageclient"; +import {ATLASCODEDIFF, CodeNormalizer, getAbapCodeNormalizer, integrationIsActive} from "./integrations"; +const ABAPGITSCHEME = "abapgit.normalized"; + +const originalUri = (u:Uri) => { + if (u.scheme !== ABAPGITSCHEME) {return u;} + const {scheme, query} = JSON.parse(u.query); + return u.with({scheme, query}); +}; + +class NormalizedProvider implements TextDocumentContentProvider { + private readonly normalizer: CodeNormalizer; + public constructor(client: BaseLanguageClient) { + this.normalizer = getAbapCodeNormalizer(client); + }; + public onDidChange?: Event | undefined; + public async provideTextDocumentContent(uri: Uri): Promise { + const origUri = originalUri(uri); + if (uri.scheme === origUri.scheme) {throw new Error("invalid URL"); }; + const raw = await workspace.openTextDocument(origUri); + return this.normalizer.normalize(raw.getText(), origUri); + } +} + +const shouldActivate = (e: TextEditor | undefined) => { + const curtab = window.tabGroups.activeTabGroup; + const uri = e?.document.uri; + const isdiff = curtab.activeTab?.input instanceof TabInputTextDiff; + if (!(isdiff && uri)) {return false;} + const relevant = + uri.path.match(/\.abap$/) || + uri.scheme === ATLASCODEDIFF && uri.fragment.match(/\.abap$/); + return relevant && !integrationIsActive(uri); +}; + +const activateNormalizer = (e: TextEditor | undefined) => { + commands.executeCommand("setContext", "abaplint.IsNormalizerEnabled", shouldActivate(e)); +}; +const toggleUrlNormalizer = (u:Uri) => { + if (u.scheme === ABAPGITSCHEME) { return originalUri(u);}; + const query = JSON.stringify({scheme:u.scheme, query:u.query}); + return u.with({scheme:ABAPGITSCHEME, query}); +}; + +const toggleNormalizer = () => { + const curtab = window.tabGroups.activeTabGroup.activeTab; + if (!(curtab?.input instanceof TabInputTextDiff)) {return;} + const {original, modified} = curtab.input; + return commands.executeCommand("vscode.diff", toggleUrlNormalizer(original), toggleUrlNormalizer(modified), curtab.label); +}; + +export const registerNormalizer = (context:ExtensionContext, client: BaseLanguageClient) => { + const onchg = window.onDidChangeActiveTextEditor(activateNormalizer); + const normalize = commands.registerCommand("abaplint.togglediffNormalize", toggleNormalizer); + const provider = workspace.registerTextDocumentContentProvider(ABAPGITSCHEME, new NormalizedProvider(client)); + context.subscriptions.push(onchg, normalize, provider); +}; \ No newline at end of file diff --git a/package.json b/package.json index 2f4773fe..87aa3755 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,12 @@ "command": "abaplint.create.default-config", "title": "Create Default Config", "category": "abaplint" + }, + { + "command": "abaplint.togglediffNormalize", + "title": "Normalize files", + "icon": "$(law)", + "category": "abaplint" } ], "jsonValidation": [ @@ -117,6 +123,11 @@ "when": "editorLangId == 'abap'", "command": "abaplint.show", "group": "navigation" + }, + { + "command": "abaplint.togglediffNormalize", + "group": "navigation", + "when": "isInDiffEditor && abaplint.IsNormalizerEnabled" } ], "file/newFile": [ @@ -231,6 +242,22 @@ "description": "List of rules that should not be triggered on formatting" } } + }, + { + "order": 50, + "id": "codenormalization", + "title": "Diff code normalization for bitbucket", + "properties": { + "abaplint.codeNormalization": { + "type": "string", + "enum": [ + "On by default", + "Off by default", + "deactivated" + ], + "default": "Off by default" + } + } } ] }, @@ -271,6 +298,7 @@ "path-browserify": "^1.0.1", "ts-loader": "^9.5.1", "typescript": "^5.6.3", + "vm-browserify": "^1.1.2", "webpack": "^5.96.1", "webpack-cli": "^5.1.4" } diff --git a/server/src/codenormalizer.ts b/server/src/codenormalizer.ts new file mode 100644 index 00000000..f0d0185f --- /dev/null +++ b/server/src/codenormalizer.ts @@ -0,0 +1,147 @@ +import {ABAPFile, ABAPObject, MemoryFile, Registry, PrettyPrinter, Config, IRegistry, RulesRunner, applyEditList, IEdit} from "@abaplint/core"; + +interface FileDetails { + file: ABAPFile + reg: IRegistry +} + +function parseAbapFile( + name: string, + abap: string +):FileDetails | undefined { + const reg = new Registry().addFile(new MemoryFile(name, abap)).parse(); + const objects = [...reg.getObjects()].filter(ABAPObject.is); + const file = objects[0]?.getABAPFiles()[0]; + if (file) {return {file, reg};}; + return; +} + +const getConfig = ():Config => { + const rules = { + align_pseudo_comments:{ + exclude: [], + severity: "Error", + }, + align_parameters:{ + exclude: [], + severity: "Error", + }, + align_type_expressions:{ + exclude: [], + severity: "Error", + }, + in_statement_indentation:{ + exclude: [], + severity: "Error", + blockStatements: 2, + ignoreExceptions: true, + }, + sequential_blank: { + lines: 4, + }, + contains_tab:{ + exclude: [], + severity: "Error", + spaces: 1, + }, + indentation:{ + exclude: [], + severity: "Error", + ignoreExceptions: true, + alignTryCatch: false, + selectionScreenBlockIndentation: false, + globalClassSkipFirst: false, + ignoreGlobalClassDefinition: false, + ignoreGlobalInterface: false, + }, + keyword_case: { + style: "lower", + ignoreExceptions: true, + ignoreLowerClassImplmentationStatement: true, + ignoreGlobalClassDefinition: false, + ignoreGlobalInterface: false, + ignoreFunctionModuleName: false, + }, + }; + return new Config(JSON.stringify({rules})); +}; + +const HasOverlaps = (edit1:IEdit, edit2:IEdit) => { + const files1 = new Set(Object.keys(edit1)); + for (const file of Object.keys(edit2).filter(x => files1.has(x))) { + for (const filedit1 of edit1[file]) { + for (const filedit2 of edit2[file]) { + if (filedit2.range.start.getRow() <= filedit1.range.start.getRow() + && filedit2.range.end.getRow() >= filedit1.range.start.getRow()) { + return true; + } + if (filedit2.range.start.getRow() <= filedit1.range.end.getRow() + && filedit2.range.end.getRow() >= filedit1.range.end.getRow()) { + return true; + } + } + } + } + return false; +}; + +const removeOverlapping = (edits:IEdit[]) => { + return edits.filter((ed, i) => { + if (i <= 0) {return true;} + for (let idx = 0; idx < i; idx++) { + if (HasOverlaps(ed, edits[idx])) {return false;} + } + return true; + }); +}; + +type IRule = ReturnType[0] // expose hidden IRule interface +const applyRule = (reg:IRegistry, obj:ABAPObject, rule:IRule) => { + rule.initialize(reg); + const issues = new RulesRunner(reg).excludeIssues([...rule.run(obj)]); + const edits = issues + .map(a => a.getDefaultFix()) + .filter(e => typeof e !== "undefined"); + if (edits.length) { + const nonconflicting = removeOverlapping(edits); + const changed = applyEditList(reg, nonconflicting); + reg.parse(); + const needReapplying = !!changed.length && nonconflicting.length < edits.length; + return needReapplying; + } + return false; +}; + +const applyRules = (f:FileDetails, config:Config) => { + const objects = [...f.reg.getObjects()].filter(ABAPObject.is);; + const obj = objects[0]; + if (obj?.getFiles().length !== 1) {return f;} + for (const rule of config.getEnabledRules()) { + let needtoApply = true; + let count = 0; + while (needtoApply) { + needtoApply = count++ < 10 && applyRule(f.reg, obj, rule); + }; + } + const file = obj.getABAPFiles()[0] || f.file; + return {...f, file}; +}; + +let normalizer: (path: string, source: string) => Promise; + +export const getNormalizer = () => { + if (!normalizer) { + const config = getConfig(); + normalizer = async (path: string, source: string) => { + const name = path.replace(/.*\//, ""); + const f = parseAbapFile(name, source); + if (f) { + const fixed = await applyRules(f, config); + const result = new PrettyPrinter(fixed.file, config).run(); + if (result) {return result;}; + } + throw new Error(`Abaplint formatting failed for ${path}`); + }; + } + return normalizer; +}; \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index 11241a8d..cec7d30a 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -5,6 +5,7 @@ import * as abaplint from "@abaplint/core"; import {TextDocument} from "vscode-languageserver-textdocument"; import {Handler} from "./handler"; import {FsProvider, FileOperations} from "./file_operations"; +import {getNormalizer} from "./codenormalizer"; let connection: LServer.Connection; if (fs.read === undefined) { @@ -282,5 +283,15 @@ connection.onRequest("abaplint/unittests/list/request", async () => { handler.onListUnitTests(); }); +connection.onRequest("abaplint/normalize", async (data) => { + try { + const {path, source} = data; + return await getNormalizer()(path, source); + } catch (error) { + connection.console.error("message" in error ? error.message : error); + return data?.source; + } +}); + documents.listen(connection); connection.listen();