Skip to content

Commit 545e3f2

Browse files
committed
(PowerShellGH-1336) Add syntax aware folding provider
Previously the Powershell extension used the default VSCode indentation based folding regions. This commit adds the skeleton for a syntax aware, client-side folding provider as per the API introduced in VSCode 1.23.0 * The client side detection uses the PowerShell Textmate grammar file and the vscode-text node module to parse the text file into tokens which will be matched in later commits. * However due to the way vscode imports the vscode-textmate module we can't simply just import it, instead we need to use file based require statements microsoft/vscode#46281 * This also means it's difficult to use any typings exposed in that module. As we only need one interface, this is replicated verbatim in the provider class
1 parent 790e6e2 commit 545e3f2

File tree

3 files changed

+212
-1
lines changed

3 files changed

+212
-1
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"publisher": "ms-vscode",
66
"description": "Develop PowerShell scripts in Visual Studio Code!",
77
"engines": {
8-
"vscode": "^1.22.0"
8+
"vscode": "^1.23.0"
99
},
1010
"license": "SEE LICENSE IN LICENSE.txt",
1111
"homepage": "https://github.com/PowerShell/vscode-powershell/blob/master/README.md",

src/features/Folding.ts

+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import * as path from "path";
2+
import * as vscode from "vscode";
3+
import {
4+
DocumentSelector,
5+
LanguageClient,
6+
} from "vscode-languageclient";
7+
import { IFeature } from "../feature";
8+
9+
const tm = getCoreNodeModule("vscode-textmate");
10+
// Returns a node module installed with VSCode, or null if it fails.
11+
// https://github.com/Microsoft/vscode/issues/46281
12+
function getCoreNodeModule(moduleName: string) {
13+
try {
14+
return require(`${vscode.env.appRoot}/node_modules.asar/${moduleName}`);
15+
// tslint:disable-next-line:no-empty
16+
} catch (err) { }
17+
18+
try {
19+
return require(`${vscode.env.appRoot}/node_modules/${moduleName}`);
20+
// tslint:disable-next-line:no-empty
21+
} catch (err) { }
22+
23+
return null;
24+
}
25+
26+
interface IExtensionGrammar {
27+
language?: string;
28+
scopeName?: string;
29+
path?: string;
30+
extensionPath?: string;
31+
embeddedLanguages?: {
32+
[scopeName: string]: string,
33+
}; injectTo?: string[];
34+
}
35+
36+
interface IExtensionLanguage {
37+
id: string;
38+
configuration: string;
39+
}
40+
41+
interface IExtensionPackage {
42+
contributes?: {
43+
languages?: IExtensionLanguage[],
44+
grammars?: IExtensionGrammar[],
45+
};
46+
}
47+
48+
// Need to reproduce the IToken interface from vscode-textmate due to
49+
// the odd way it has to be required
50+
// https://github.com/Microsoft/vscode-textmate/blob/46af9487e1c8fa78aa1f2e228dcc387b78e2d6c5/release/main.d.ts#L161-L165
51+
export interface IToken {
52+
startIndex: number;
53+
readonly endIndex: number;
54+
readonly scopes: string[];
55+
}
56+
57+
// The different types of matched textmate tokens.
58+
// Mirrors the enum at https://code.visualstudio.com/docs/extensionAPI/vscode-api#FoldingRangeKind
59+
enum MatchType {
60+
Comment = 1,
61+
Imports = 2,
62+
Region = 3,
63+
}
64+
65+
// Defines a pair of tokens (start, end) or pair or line numbers (startLine, endLine) which define
66+
// a folding range in source code.
67+
class MatchedToken {
68+
// Start IToken object of the range. Optional if startLine is specified
69+
public start: IToken;
70+
// The starting zero-based line number of the range.
71+
public startline: number;
72+
// End IToken object of the range. Optional if startLine is specified
73+
public end: IToken;
74+
// The ending zero-based line number of the range.
75+
public endline: number;
76+
// The type of range this is
77+
public matchType: MatchType;
78+
79+
constructor(start: IToken = null,
80+
end: IToken = null,
81+
startLine: number = null,
82+
endLine: number = null,
83+
matchType: MatchType,
84+
document: vscode.TextDocument) {
85+
this.start = start;
86+
this.end = end;
87+
if (startLine === null) {
88+
this.startline = document.positionAt(start.startIndex).line;
89+
} else {
90+
this.startline = startLine;
91+
}
92+
if (endLine === null) {
93+
this.endline = document.positionAt(end.startIndex).line;
94+
} else {
95+
this.endline = endLine;
96+
}
97+
this.matchType = matchType;
98+
}
99+
100+
// Returns whether this pair of matched tokens/line numbers, is a valid folding range
101+
public isValidRange(): boolean {
102+
// Folding ranges must span at least 2 lines
103+
return (this.endline - this.startline >= 1);
104+
}
105+
106+
// Creates a vscode.FoldingRange object based on this object
107+
public toFoldingRange(): vscode.FoldingRange {
108+
let rk: vscode.FoldingRangeKind = vscode.FoldingRangeKind.Region;
109+
switch (this.matchType) {
110+
case MatchType.Comment: { rk = vscode.FoldingRangeKind.Comment; break; }
111+
case MatchType.Imports: { rk = vscode.FoldingRangeKind.Imports; break; }
112+
}
113+
return new vscode.FoldingRange(this.startline, this.endline, rk);
114+
}
115+
}
116+
117+
interface IMatchedTokenList extends Array<MatchedToken> {}
118+
119+
export class FoldingProvider implements vscode.FoldingRangeProvider {
120+
private powershellGrammar;
121+
122+
public async provideFoldingRanges(
123+
document: vscode.TextDocument,
124+
context: vscode.FoldingContext,
125+
token: vscode.CancellationToken,
126+
): Promise<vscode.FoldingRange[]> {
127+
128+
const foldingRanges = [];
129+
130+
// Convert the document text into a series of grammar tokens
131+
const tokens = this.grammar().tokenizeLine(document.getText()).tokens;
132+
133+
// Parse the token list looking for matching tokens and return
134+
// a list of IMatchedTokens. Then filter the list and only return matches
135+
// that are a valid folding range e.g. It meets a minimum line span limit
136+
const foldableTokens = this.matchGrammarTokens(tokens, document)
137+
.filter((item) => item.isValidRange());
138+
139+
// Sort the list of matched tokens, starting at the top of the document,
140+
// and ensure that, in the case of multiple ranges starting the same line,
141+
// that the largest range (i.e. most number of lines spanned) is sorted
142+
// first. This is needed as vscode will just ignore any duplicate folding
143+
// ranges.
144+
foldableTokens.sort((a: MatchedToken, b: MatchedToken) => {
145+
// Initially look at the start line
146+
if (a.startline > b.startline) { return 1; }
147+
if (a.startline < b.startline) { return -1; }
148+
// They have the same start line so now consider the end line.
149+
// The biggest line range is sorted first
150+
if (a.endline > b.endline) { return -1; }
151+
if (a.endline < b.endline) { return 1; }
152+
// They're the same
153+
return 0;
154+
});
155+
156+
// Convert the matched token list into a FoldingRange[]
157+
foldableTokens.forEach((item) => { foldingRanges.push(item.toFoldingRange()); });
158+
159+
return foldingRanges;
160+
}
161+
162+
// Given a list of tokens, return a list of matched tokens/line numbers regions
163+
private matchGrammarTokens(tokens, document: vscode.TextDocument): IMatchedTokenList {
164+
const matchedTokens = [];
165+
166+
return matchedTokens;
167+
}
168+
169+
// Search all of the loaded extenions for the PowerShell grammar file
170+
private powerShellGrammarPath(): string {
171+
try {
172+
const psGrammars =
173+
vscode.extensions.all
174+
.filter((x) => x.packageJSON && x.packageJSON.contributes && x.packageJSON.contributes.grammars)
175+
.reduce((a: IExtensionGrammar[], b) =>
176+
[...a, ...(b.packageJSON as IExtensionPackage).contributes.grammars
177+
.map((x) => Object.assign({ extensionPath: b.extensionPath }, x))], [])
178+
.filter((x) => x.language === "powershell");
179+
if (psGrammars.length === 0) { return ""; }
180+
return path.join(psGrammars[0].extensionPath, psGrammars[0].path);
181+
} catch (err) { return ""; }
182+
}
183+
184+
// Returns the PowerShell grammar parser
185+
private grammar() {
186+
if (this.powershellGrammar !== undefined) { return this.powershellGrammar; }
187+
const registry = new tm.Registry();
188+
this.powershellGrammar = registry.loadGrammarFromPathSync(this.powerShellGrammarPath());
189+
return this.powershellGrammar;
190+
}
191+
192+
}
193+
194+
export class FoldingFeature implements IFeature {
195+
private foldingProvider: FoldingProvider;
196+
197+
constructor(documentSelector: DocumentSelector) {
198+
this.foldingProvider = new FoldingProvider();
199+
vscode.languages.registerFoldingRangeProvider(
200+
documentSelector,
201+
this.foldingProvider);
202+
}
203+
204+
// tslint:disable-next-line:no-empty
205+
public dispose(): any { }
206+
207+
// tslint:disable-next-line:no-empty
208+
public setLanguageClient(languageclient: LanguageClient): void { }
209+
}

src/main.ts

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DocumentFormatterFeature } from "./features/DocumentFormatter";
1818
import { ExamplesFeature } from "./features/Examples";
1919
import { ExpandAliasFeature } from "./features/ExpandAlias";
2020
import { ExtensionCommandsFeature } from "./features/ExtensionCommands";
21+
import { FoldingFeature } from "./features/Folding";
2122
import { GenerateBugReportFeature } from "./features/GenerateBugReport";
2223
import { HelpCompletionFeature } from "./features/HelpCompletion";
2324
import { NewFileOrProjectFeature } from "./features/NewFileOrProject";
@@ -132,6 +133,7 @@ export function activate(context: vscode.ExtensionContext): void {
132133
new SpecifyScriptArgsFeature(context),
133134
new HelpCompletionFeature(logger),
134135
new CustomViewsFeature(),
136+
new FoldingFeature(documentSelector),
135137
];
136138

137139
sessionManager.setExtensionFeatures(extensionFeatures);

0 commit comments

Comments
 (0)