Skip to content

Commit e50704e

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 file, but not exported * Logging is added to help diagnose potential issues
1 parent 9bf58a7 commit e50704e

File tree

3 files changed

+340
-1
lines changed

3 files changed

+340
-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

+337
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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+
import { Logger } from "../logging";
9+
10+
/**
11+
* Defines a grammar file that is in a VS Code Extension
12+
*/
13+
interface IExtensionGrammar {
14+
/**
15+
* The name of the language, e.g. powershell
16+
*/
17+
language?: string;
18+
/**
19+
* The absolute path to the grammar file
20+
*/
21+
path?: string;
22+
/**
23+
* The path to the extension
24+
*/
25+
extensionPath?: string;
26+
}
27+
28+
/**
29+
* Defines a VS Code extension with minimal properities for grammar contribution
30+
*/
31+
interface IExtensionPackage {
32+
/**
33+
* Hashtable of items this extension contributes
34+
*/
35+
contributes?: {
36+
/**
37+
* Array of grammars this extension supports
38+
*/
39+
grammars?: IExtensionGrammar[],
40+
};
41+
}
42+
43+
/**
44+
* Defines a grammar token in a text document
45+
* Need to reproduce the IToken interface from vscode-textmate due to the odd way it has to be required
46+
* https://github.com/Microsoft/vscode-textmate/blob/46af9487e1c8fa78aa1f2e2/release/main.d.ts#L161-L165
47+
*/
48+
interface IToken {
49+
/**
50+
* Zero based offset where the token starts
51+
*/
52+
startIndex: number;
53+
/**
54+
* Zero based offset where the token ends
55+
*/
56+
readonly endIndex: number;
57+
/**
58+
* Array of scope names that the token is a member of
59+
*/
60+
readonly scopes: string[];
61+
}
62+
63+
/**
64+
* Defines a list of grammar tokens, typically for an entire text document
65+
*/
66+
interface ITokenList extends Array<IToken> { }
67+
68+
/**
69+
* Due to how the vscode-textmate library is required, we need to minimally define a Grammar object, which
70+
* can be used to tokenize a text document.
71+
* https://github.com/Microsoft/vscode-textmate/blob/46af9487e1c8fa78aa1f2e2/release/main.d.ts#L92-L108
72+
*/
73+
interface IGrammar {
74+
/**
75+
* Tokenize `lineText` using previous line state `prevState`.
76+
*/
77+
tokenizeLine(lineText: any, prevState: any): any;
78+
}
79+
80+
/**
81+
* Defines a pair line numbers which describes a potential folding range in a text document
82+
*/
83+
class LineNumberRange {
84+
/**
85+
* The zero-based line number of the start of the range
86+
*/
87+
public startline: number;
88+
/**
89+
* The zero-based line number of the end of the range
90+
*/
91+
public endline: number;
92+
/**
93+
* The type of range this represents
94+
*/
95+
public rangeKind: vscode.FoldingRangeKind;
96+
97+
constructor(
98+
rangeKind: vscode.FoldingRangeKind,
99+
) {
100+
this.rangeKind = rangeKind;
101+
}
102+
103+
/**
104+
* Build the range based on a pair of grammar tokens
105+
* @param start The token where the range starts
106+
* @param end The token where the range ends
107+
* @param document The text document
108+
* @returns Built LineNumberRange object
109+
*/
110+
public fromTokenPair(
111+
start: IToken,
112+
end: IToken,
113+
document: vscode.TextDocument,
114+
): LineNumberRange {
115+
this.startline = document.positionAt(start.startIndex).line;
116+
this.endline = document.positionAt(end.startIndex).line;
117+
return this;
118+
}
119+
120+
/**
121+
* Build the range based on a pair of line numbers
122+
* @param startLine The line where the range starts
123+
* @param endLine The line where the range ends
124+
* @returns Built LineNumberRange object
125+
*/
126+
public fromLinePair(
127+
startLine: number,
128+
endLine: number,
129+
): LineNumberRange {
130+
this.startline = startLine;
131+
this.endline = endLine;
132+
return this;
133+
}
134+
135+
/**
136+
* Whether this line number range, is a valid folding range in the document
137+
* @returns Whether the range passes all validation checks
138+
*/
139+
public isValidRange(): boolean {
140+
// Start and end lines must be defined and positive integers
141+
if (this.startline == null || this.endline == null) { return false; }
142+
if (this.startline < 0 || this.endline < 0) { return false; }
143+
// End line number cannot be before the start
144+
if (this.startline > this.endline) { return false; }
145+
// Folding ranges must span at least 2 lines
146+
return (this.endline - this.startline >= 1);
147+
}
148+
149+
/**
150+
* Creates a vscode.FoldingRange object based on this object
151+
* @returns A Folding Range object for use with the Folding Provider
152+
*/
153+
public toFoldingRange(): vscode.FoldingRange {
154+
return new vscode.FoldingRange(this.startline, this.endline, this.rangeKind);
155+
}
156+
}
157+
158+
/**
159+
* An array of line number ranges
160+
*/
161+
interface ILineNumberRangeList extends Array<LineNumberRange> { }
162+
163+
/**
164+
* A PowerShell syntax aware Folding Provider
165+
*/
166+
export class FoldingProvider implements vscode.FoldingRangeProvider {
167+
private powershellGrammar: IGrammar;
168+
169+
constructor(
170+
powershellGrammar: IGrammar,
171+
) {
172+
this.powershellGrammar = powershellGrammar;
173+
}
174+
175+
/**
176+
* Given a text document, parse the document and return a list of code folding ranges.
177+
* @param document Text document to parse
178+
* @param context Not used
179+
* @param token Not used
180+
*/
181+
public async provideFoldingRanges(
182+
document: vscode.TextDocument,
183+
context: vscode.FoldingContext,
184+
token: vscode.CancellationToken,
185+
): Promise<vscode.FoldingRange[]> {
186+
187+
// If the grammar hasn't been setup correctly, return empty result
188+
if (this.powershellGrammar == null) { return []; }
189+
190+
// Convert the document text into a series of grammar tokens
191+
const tokens: ITokenList = this.powershellGrammar.tokenizeLine(document.getText(), null).tokens;
192+
193+
// Parse the token list looking for matching tokens and return
194+
// a list of LineNumberRange objects. Then filter the list and only return matches
195+
// that are a valid folding range e.g. It meets a minimum line span limit
196+
const foldableRegions = this.extractFoldableRegions(tokens, document)
197+
.filter((item) => item.isValidRange());
198+
199+
// Sort the list of matched tokens, starting at the top of the document,
200+
// and ensure that, in the case of multiple ranges starting the same line,
201+
// that the largest range (i.e. most number of lines spanned) is sorted
202+
// first. This is needed as vscode will just ignore any duplicate folding
203+
// ranges.
204+
foldableRegions.sort((a: LineNumberRange, b: LineNumberRange) => {
205+
// Initially look at the start line
206+
if (a.startline > b.startline) { return 1; }
207+
if (a.startline < b.startline) { return -1; }
208+
// They have the same start line so now consider the end line.
209+
// The biggest line range is sorted first
210+
if (a.endline > b.endline) { return -1; }
211+
if (a.endline < b.endline) { return 1; }
212+
// They're the same
213+
return 0;
214+
});
215+
216+
// Convert the matched token list into a FoldingRange[]
217+
const foldingRanges = [];
218+
foldableRegions.forEach((item) => { foldingRanges.push(item.toFoldingRange()); });
219+
220+
return foldingRanges;
221+
}
222+
223+
/**
224+
* Given a list of tokens, return a list of line number ranges which could be folding regions in the document
225+
* @param tokens List of grammar tokens to parse
226+
* @param document The source text document
227+
* @returns A list of LineNumberRange objects of the possible document folding regions
228+
*/
229+
private extractFoldableRegions(
230+
tokens: ITokenList,
231+
document: vscode.TextDocument,
232+
): ILineNumberRangeList {
233+
const matchedTokens: ILineNumberRangeList = [];
234+
235+
return matchedTokens;
236+
}
237+
}
238+
239+
export class FoldingFeature implements IFeature {
240+
private foldingProvider: FoldingProvider;
241+
242+
/**
243+
* Constructs a handler for the FoldingProvider. It returns success if the required grammar file can not be located
244+
* but does not regist a provider. This causes VS Code to instead still use the indentation based provider
245+
* @param logger The logging object to send messages to
246+
* @param documentSelector documentSelector object for this Folding Provider
247+
*/
248+
constructor(private logger: Logger, documentSelector: DocumentSelector) {
249+
const grammar: IGrammar = this.grammar(logger);
250+
251+
// If the PowerShell grammar is not available for some reason, don't register a folding provider,
252+
// which reverts VSCode to the default indentation style folding
253+
if (grammar == null) {
254+
logger.writeWarning("Unable to load the PowerShell grammar file");
255+
return;
256+
}
257+
258+
this.foldingProvider = new FoldingProvider(grammar);
259+
vscode.languages.registerFoldingRangeProvider(documentSelector, this.foldingProvider);
260+
261+
logger.write("Syntax Folding Provider registered");
262+
}
263+
264+
/* dispose() is required by the IFeature interface, but is not required by this feature */
265+
public dispose(): any { return undefined; }
266+
267+
/* setLanguageClient() is required by the IFeature interface, but is not required by this feature */
268+
public setLanguageClient(languageclient: LanguageClient): void { return undefined; }
269+
270+
/**
271+
* Returns the PowerShell grammar parser, from the vscode-textmate node module
272+
* @param logger The logging object to send messages to
273+
* @returns A grammar parser for the PowerShell language is succesful or undefined if an error occured
274+
*/
275+
public grammar(logger: Logger): IGrammar {
276+
const tm = this.getCoreNodeModule("vscode-textmate", logger);
277+
if (tm == null) { return undefined; }
278+
logger.writeDiagnostic(`Loaded the vscode-textmate module`);
279+
const registry = new tm.Registry();
280+
if (registry == null) { return undefined; }
281+
logger.writeDiagnostic(`Created the textmate Registry`);
282+
const grammarPath = this.powerShellGrammarPath();
283+
if (grammarPath == null) { return undefined; }
284+
logger.writeDiagnostic(`PowerShell grammar file specified as ${grammarPath}`);
285+
try {
286+
return registry.loadGrammarFromPathSync(grammarPath);
287+
} catch (err) {
288+
logger.writeError(`Error while loading the PowerShell grammar file at ${grammarPath}`, err);
289+
}
290+
}
291+
292+
/**
293+
* Returns a node module installed within VSCode, or null if it fails.
294+
* Some node modules (e.g. vscode-textmate) cannot be required directly, instead the known module locations
295+
* must be tried. Documented in https://github.com/Microsoft/vscode/issues/46281
296+
* @param moduleName Name of the module to load e.g. vscode-textmate
297+
* @param logger The logging object to send messages to
298+
* @returns The required module, or null if the module cannot be required
299+
*/
300+
private getCoreNodeModule(moduleName: string, logger: Logger) {
301+
// Attempt to load the module from known locations
302+
const loadLocations: string[] = [
303+
`${vscode.env.appRoot}/node_modules.asar/${moduleName}`,
304+
`${vscode.env.appRoot}/node_modules/${moduleName}`,
305+
];
306+
307+
for (const filename of loadLocations) {
308+
try {
309+
const mod = require(filename);
310+
logger.writeDiagnostic(`Succesfully required ${filename}`);
311+
return mod;
312+
} catch (err) {
313+
logger.writeError(`Error while attempting to require ${filename}`, err);
314+
}
315+
}
316+
return null;
317+
}
318+
319+
/**
320+
* Search all of the loaded extenions for the PowerShell grammar file
321+
* @returns The absolute path to the PowerShell grammar file. Returns undefined if the path cannot be located.
322+
*/
323+
private powerShellGrammarPath(): string {
324+
// Go through all the extension packages and search for PowerShell grammars,
325+
// returning the path to the first we find
326+
for (const ext of vscode.extensions.all) {
327+
if (!(ext.packageJSON && ext.packageJSON.contributes && ext.packageJSON.contributes.grammars)) {
328+
continue;
329+
}
330+
for (const grammar of ext.packageJSON.contributes.grammars) {
331+
if (grammar.language !== "powershell") { continue; }
332+
return path.join(ext.extensionPath, grammar.path);
333+
}
334+
}
335+
return undefined;
336+
}
337+
}

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(logger, documentSelector),
135137
];
136138

137139
sessionManager.setExtensionFeatures(extensionFeatures);

0 commit comments

Comments
 (0)