Skip to content

Add feature to format PowerShell source files #426

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jan 17, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,16 @@
"type": "boolean",
"default": false,
"description": "Launches the language service with the /waitForDebugger flag to force it to wait for a .NET debugger to attach before proceeding."
},
"powershell.codeFormatting.openBraceOnSameLine":{
"type":"boolean",
"default": true,
"description": "Places open brace on the same line as its associated statement."
},
"powershell.codeFormatting.newLineAfterOpenBrace":{
"type":"boolean",
"default": true,
"description": "A new line must follow an open brace."
}
}
}
Expand Down
272 changes: 272 additions & 0 deletions src/features/DocumentFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/

import vscode = require('vscode');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget the copyright header

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed.

import {
languages,
TextDocument,
TextEdit,
FormattingOptions,
CancellationToken,
DocumentFormattingEditProvider,
DocumentRangeFormattingEditProvider,
Range,
} from 'vscode';
import { LanguageClient, RequestType, NotificationType } from 'vscode-languageclient';
import Window = vscode.window;
import { IFeature } from '../feature';
import * as Settings from '../settings';
import * as Utils from '../utils';

export namespace ScriptFileMarkersRequest {
export const type: RequestType<any, any, void> = { get method(): string { return "powerShell/getScriptFileMarkers"; } };
}

// TODO move some of the common interface to a separate file?
interface ScriptFileMarkersRequestParams {
filePath: string;
settings: any;
}

interface ScriptFileMarkersRequestResultParams {
markers: ScriptFileMarker[];
}

interface ScriptFileMarker {
message: string;
level: ScriptFileMarkerLevel;
scriptRegion: ScriptRegion;
correction: MarkerCorrection;
}

enum ScriptFileMarkerLevel {
Information = 0,
Warning,
Error
}

interface ScriptRegion {
file: string;
text: string;
startLineNumber: number;
startColumnNumber: number;
startOffset: number;
endLineNumber: number;
endColumnNumber: number;
endOffset: number;
}

interface MarkerCorrection {
name: string;
edits: ScriptRegion[]
}

function editComparer(leftOperand: ScriptRegion, rightOperand: ScriptRegion): number {
if (leftOperand.startLineNumber < rightOperand.startLineNumber) {
return -1;
} else if (leftOperand.startLineNumber > rightOperand.startLineNumber) {
return 1;
} else {
if (leftOperand.startColumnNumber < rightOperand.startColumnNumber) {
return -1;
}
else if (leftOperand.startColumnNumber > rightOperand.startColumnNumber) {
return 1;
}
else {
return 0;
}
}
}

class PSDocumentFormattingEditProvider implements DocumentFormattingEditProvider, DocumentRangeFormattingEditProvider {
private languageClient: LanguageClient;

// The order in which the rules will be executed starting from the first element.
private readonly ruleOrder: string[] = [
"PSPlaceCloseBrace",
"PSPlaceOpenBrace",
"PSUseConsistentIndentation"];

// Allows edits to be undone and redone is a single step.
// It is usefuld to have undo stops after every edit while debugging
// hence we keep this as an option but set it true by default.
private aggregateUndoStop: boolean;

constructor(aggregateUndoStop = true) {
this.aggregateUndoStop = aggregateUndoStop;
}

provideDocumentFormattingEdits(
document: TextDocument,
options: FormattingOptions,
token: CancellationToken): TextEdit[] | Thenable<TextEdit[]> {
return this.provideDocumentRangeFormattingEdits(document, null, options, token);
}

provideDocumentRangeFormattingEdits(
document: TextDocument,
range: Range,
options: FormattingOptions,
token: CancellationToken): TextEdit[] | Thenable<TextEdit[]> {
return this.executeRulesInOrder(document, range, options, 0);
}

executeRulesInOrder(
document: TextDocument,
range: Range,
options: FormattingOptions,
index: number): Thenable<TextEdit[]> | TextEdit[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we could simplify this code a lot by using the TextEdit class as the means for transporting the formatting edits back and forth between extension and server. Check out VS Code's TextEdit class compared to the one we have in the Protocol assembly, they are exactly the same.

I'd recommend pushing as much of this logic to the service side as you can and only sending back the valid TextEdits from the server which will be applied by the client. In fact, I think we should implement this based on the exact protocol messages that are coming in the Language Server Protocol 3.0 since we'll be snapping to that pretty soon anyway. Check these out:

https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-2-x.md#textDocument_formatting

The request type is fairly similar to yours now and the response type is simply an array of TextEdit. Implementing it this way will future-proof us a lot better.

if (this.languageClient !== null && index < this.ruleOrder.length) {
let rule = this.ruleOrder[index];
let uniqueEdits: ScriptRegion[] = [];
let edits: ScriptRegion[];
return this.languageClient.sendRequest(
ScriptFileMarkersRequest.type,
{
filePath: document.fileName,
settings: this.getSettings(rule)
})
.then((result: ScriptFileMarkersRequestResultParams) => {
edits = result.markers.map(marker => { return marker.correction.edits[0]; });

// sort in decending order of the edits
edits.sort((left: ScriptRegion, right: ScriptRegion) => {
return -1 * editComparer(left, right);
});

// We cannot handle multiple edits at the same point hence we
// filter the markers so that there is only one edit per line
// This ideally should not happen but it is good to have some additional safeguard
if (edits.length > 0) {
uniqueEdits.push(edits[0]);
for (let edit of edits.slice(1)) {
if (editComparer(uniqueEdits[uniqueEdits.length - 1], edit) !== 0) {
uniqueEdits.push(edit);
}
}
}

// we need to update the range as the edits might
// have changed the original layout
if (range !== null) {
let tempRange: Range = this.getSelectionRange(document);
if (tempRange !== null) {
range = tempRange;
}
}

// we do not return a valid array because our text edits
// need to be executed in a particular order and it is
// easier if we perform the edits ourselves
return this.applyEdit(uniqueEdits, range, 0, index);
})
.then(() => {
// execute the same rule again if we left out violations
// on the same line
if (uniqueEdits.length !== edits.length) {
return this.executeRulesInOrder(document, range, options, index);
}
return this.executeRulesInOrder(document, range, options, index + 1);
});
} else {
return TextEdit[0];
}
}

applyEdit(edits: ScriptRegion[], range: Range, markerIndex: number, ruleIndex: number): Thenable<void> {
if (markerIndex >= edits.length) {
return;
}

let undoStopAfter = !this.aggregateUndoStop || (ruleIndex === this.ruleOrder.length - 1 && markerIndex === edits.length - 1);
let undoStopBefore = !this.aggregateUndoStop || (ruleIndex === 0 && markerIndex === 0);
let edit: ScriptRegion = edits[markerIndex];
let editRange: Range = new vscode.Range(
edit.startLineNumber - 1,
edit.startColumnNumber - 1,
edit.endLineNumber - 1,
edit.endColumnNumber - 1);
if (range === null || range.contains(editRange)) {
return Window.activeTextEditor.edit((editBuilder) => {
editBuilder.replace(
editRange,
edit.text);
},
{
undoStopAfter: undoStopAfter,
undoStopBefore: undoStopBefore
}).then((isEditApplied) => {
return this.applyEdit(edits, range, markerIndex + 1, ruleIndex);
}); // TODO handle rejection
}
else {
return this.applyEdit(edits, range, markerIndex + 1, ruleIndex);
}
}

getSelectionRange(document: TextDocument): Range {
let editor = vscode.window.visibleTextEditors.find(editor => editor.document === document);
if (editor !== undefined) {
return editor.selection as Range;
}

return null;
}

setLanguageClient(languageClient: LanguageClient): void {
this.languageClient = languageClient;
}

getSettings(rule: string): any {
let psSettings: Settings.ISettings = Settings.load(Utils.PowerShellLanguageId);
let ruleSettings = new Object();
ruleSettings["Enable"] = true;

switch (rule) {
case "PSPlaceOpenBrace":
ruleSettings["OnSameLine"] = psSettings.codeFormatting.openBraceOnSameLine;
ruleSettings["NewLineAfter"] = psSettings.codeFormatting.newLineAfterOpenBrace;
break;

case "PSUseConsistentIndentation":
ruleSettings["IndentationSize"] = vscode.workspace.getConfiguration("editor").get<number>("tabSize");
break;

default:
break;
}

let settings: Object = new Object();
settings[rule] = ruleSettings;
return settings;
}
}

export class DocumentFormatterFeature implements IFeature {
private formattingEditProvider: vscode.Disposable;
private rangeFormattingEditProvider: vscode.Disposable;
private languageClient: LanguageClient;
private documentFormattingEditProvider: PSDocumentFormattingEditProvider;

constructor() {
this.documentFormattingEditProvider = new PSDocumentFormattingEditProvider();
this.formattingEditProvider = vscode.languages.registerDocumentFormattingEditProvider(
"powershell",
this.documentFormattingEditProvider);
this.rangeFormattingEditProvider = vscode.languages.registerDocumentRangeFormattingEditProvider(
"powershell",
this.documentFormattingEditProvider);
}

public setLanguageClient(languageclient: LanguageClient): void {
this.languageClient = languageclient;
this.documentFormattingEditProvider.setLanguageClient(languageclient);
}

public dispose(): any {
this.formattingEditProvider.dispose();
this.rangeFormattingEditProvider.dispose();
}
}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { SelectPSSARulesFeature } from './features/SelectPSSARules';
import { FindModuleFeature } from './features/PowerShellFindModule';
import { NewFileOrProjectFeature } from './features/NewFileOrProject';
import { ExtensionCommandsFeature } from './features/ExtensionCommands';
import { DocumentFormatterFeature } from './features/DocumentFormatter';

// NOTE: We will need to find a better way to deal with the required
// PS Editor Services version...
Expand Down Expand Up @@ -104,6 +105,7 @@ export function activate(context: vscode.ExtensionContext): void {
new SelectPSSARulesFeature(),
new CodeActionsFeature(),
new NewFileOrProjectFeature(),
new DocumentFormatterFeature(),
new DebugSessionFeature(),
new PickPSHostProcessFeature()
];
Expand Down
36 changes: 24 additions & 12 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@

import vscode = require('vscode');

export interface ICodeFormattingSettings {
openBraceOnSameLine: boolean;
newLineAfterOpenBrace: boolean;
}

export interface IScriptAnalysisSettings {
enable?: boolean
settingsPath: string
enable?: boolean;
settingsPath: string;
}

export interface IDeveloperSettings {
Expand All @@ -19,31 +24,38 @@ export interface IDeveloperSettings {
}

export interface ISettings {
useX86Host?: boolean,
enableProfileLoading?: boolean,
scriptAnalysis?: IScriptAnalysisSettings,
developer?: IDeveloperSettings,
useX86Host?: boolean;
enableProfileLoading?: boolean;
scriptAnalysis?: IScriptAnalysisSettings;
developer?: IDeveloperSettings;
codeFormatting?: ICodeFormattingSettings;
}

export function load(myPluginId: string): ISettings {
let configuration = vscode.workspace.getConfiguration(myPluginId);
let configuration: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(myPluginId);

let defaultScriptAnalysisSettings = {
let defaultScriptAnalysisSettings: IScriptAnalysisSettings = {
enable: true,
settingsPath: ""
};

let defaultDeveloperSettings = {
let defaultDeveloperSettings: IDeveloperSettings = {
powerShellExePath: undefined,
bundledModulesPath: "../modules/",
editorServicesLogLevel: "Normal",
editorServicesWaitForDebugger: false
}
};

let defaultCodeFormattingSettings: ICodeFormattingSettings = {
openBraceOnSameLine: true,
newLineAfterOpenBrace: true
};

return {
useX86Host: configuration.get<boolean>("useX86Host", false),
enableProfileLoading: configuration.get<boolean>("enableProfileLoading", false),
scriptAnalysis: configuration.get<IScriptAnalysisSettings>("scriptAnalysis", defaultScriptAnalysisSettings),
developer: configuration.get<IDeveloperSettings>("developer", defaultDeveloperSettings)
}
developer: configuration.get<IDeveloperSettings>("developer", defaultDeveloperSettings),
codeFormatting: configuration.get<ICodeFormattingSettings>("codeFormatting", defaultCodeFormattingSettings)
};
}