Skip to content

Commit 6eb97d4

Browse files
authored
feat(eslint-plugin): (EXPERIMENTAL) begin indent rewrite (typescript-eslint#439)
1 parent 4e193ca commit 6eb97d4

19 files changed

+13038
-21
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"rules": {
1010
"comma-dangle": ["error", "always-multiline"],
1111
"curly": ["error", "all"],
12+
"no-dupe-class-members": "off",
1213
"no-mixed-operators": "error",
1314
"no-console": "off",
1415
"no-undef": "off",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// The following code is adapted from the the code in eslint.
2+
// License: https://github.com/eslint/eslint/blob/48700fc8408f394887cdedd071b22b757700fdcb/LICENSE
3+
4+
import { TSESTree } from '@typescript-eslint/typescript-estree';
5+
import createTree = require('functional-red-black-tree');
6+
7+
export type TokenOrComment = TSESTree.Token | TSESTree.Comment;
8+
export interface TreeValue {
9+
offset: number;
10+
from: TokenOrComment | null;
11+
force: boolean;
12+
}
13+
14+
/**
15+
* A mutable balanced binary search tree that stores (key, value) pairs. The keys are numeric, and must be unique.
16+
* This is intended to be a generic wrapper around a balanced binary search tree library, so that the underlying implementation
17+
* can easily be swapped out.
18+
*/
19+
export class BinarySearchTree {
20+
private rbTree = createTree<TreeValue, number>();
21+
22+
/**
23+
* Inserts an entry into the tree.
24+
*/
25+
public insert(key: number, value: TreeValue): void {
26+
const iterator = this.rbTree.find(key);
27+
28+
if (iterator.valid) {
29+
this.rbTree = iterator.update(value);
30+
} else {
31+
this.rbTree = this.rbTree.insert(key, value);
32+
}
33+
}
34+
35+
/**
36+
* Finds the entry with the largest key less than or equal to the provided key
37+
* @returns The found entry, or null if no such entry exists.
38+
*/
39+
public findLe(key: number): { key: number; value: TreeValue } {
40+
const iterator = this.rbTree.le(key);
41+
42+
return { key: iterator.key, value: iterator.value };
43+
}
44+
45+
/**
46+
* Deletes all of the keys in the interval [start, end)
47+
*/
48+
public deleteRange(start: number, end: number): void {
49+
// Exit without traversing the tree if the range has zero size.
50+
if (start === end) {
51+
return;
52+
}
53+
const iterator = this.rbTree.ge(start);
54+
55+
while (iterator.valid && iterator.key < end) {
56+
this.rbTree = this.rbTree.remove(iterator.key);
57+
iterator.next();
58+
}
59+
}
60+
}
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// The following code is adapted from the the code in eslint.
2+
// License: https://github.com/eslint/eslint/blob/48700fc8408f394887cdedd071b22b757700fdcb/LICENSE
3+
4+
import { TokenInfo } from './TokenInfo';
5+
import { BinarySearchTree, TokenOrComment } from './BinarySearchTree';
6+
import { TSESTree } from '@typescript-eslint/typescript-estree';
7+
8+
/**
9+
* A class to store information on desired offsets of tokens from each other
10+
*/
11+
export class OffsetStorage {
12+
private tokenInfo: TokenInfo;
13+
private indentSize: number;
14+
private indentType: string;
15+
private tree: BinarySearchTree;
16+
private lockedFirstTokens: WeakMap<TokenOrComment, TokenOrComment>;
17+
private desiredIndentCache: WeakMap<TokenOrComment, string>;
18+
private ignoredTokens: WeakSet<TokenOrComment>;
19+
/**
20+
* @param tokenInfo a TokenInfo instance
21+
* @param indentSize The desired size of each indentation level
22+
* @param indentType The indentation character
23+
*/
24+
constructor(tokenInfo: TokenInfo, indentSize: number, indentType: string) {
25+
this.tokenInfo = tokenInfo;
26+
this.indentSize = indentSize;
27+
this.indentType = indentType;
28+
29+
this.tree = new BinarySearchTree();
30+
this.tree.insert(0, { offset: 0, from: null, force: false });
31+
32+
this.lockedFirstTokens = new WeakMap();
33+
this.desiredIndentCache = new WeakMap();
34+
this.ignoredTokens = new WeakSet();
35+
}
36+
37+
private getOffsetDescriptor(token: TokenOrComment) {
38+
return this.tree.findLe(token.range[0]).value;
39+
}
40+
41+
/**
42+
* Sets the offset column of token B to match the offset column of token A.
43+
* **WARNING**: This matches a *column*, even if baseToken is not the first token on its line. In
44+
* most cases, `setDesiredOffset` should be used instead.
45+
* @param baseToken The first token
46+
* @param offsetToken The second token, whose offset should be matched to the first token
47+
*/
48+
public matchOffsetOf(
49+
baseToken: TokenOrComment,
50+
offsetToken: TokenOrComment,
51+
): void {
52+
/*
53+
* lockedFirstTokens is a map from a token whose indentation is controlled by the "first" option to
54+
* the token that it depends on. For example, with the `ArrayExpression: first` option, the first
55+
* token of each element in the array after the first will be mapped to the first token of the first
56+
* element. The desired indentation of each of these tokens is computed based on the desired indentation
57+
* of the "first" element, rather than through the normal offset mechanism.
58+
*/
59+
this.lockedFirstTokens.set(offsetToken, baseToken);
60+
}
61+
62+
/**
63+
* Sets the desired offset of a token.
64+
*
65+
* This uses a line-based offset collapsing behavior to handle tokens on the same line.
66+
* For example, consider the following two cases:
67+
*
68+
* (
69+
* [
70+
* bar
71+
* ]
72+
* )
73+
*
74+
* ([
75+
* bar
76+
* ])
77+
*
78+
* Based on the first case, it's clear that the `bar` token needs to have an offset of 1 indent level (4 spaces) from
79+
* the `[` token, and the `[` token has to have an offset of 1 indent level from the `(` token. Since the `(` token is
80+
* the first on its line (with an indent of 0 spaces), the `bar` token needs to be offset by 2 indent levels (8 spaces)
81+
* from the start of its line.
82+
*
83+
* However, in the second case `bar` should only be indented by 4 spaces. This is because the offset of 1 indent level
84+
* between the `(` and the `[` tokens gets "collapsed" because the two tokens are on the same line. As a result, the
85+
* `(` token is mapped to the `[` token with an offset of 0, and the rule correctly decides that `bar` should be indented
86+
* by 1 indent level from the start of the line.
87+
*
88+
* This is useful because rule listeners can usually just call `setDesiredOffset` for all the tokens in the node,
89+
* without needing to check which lines those tokens are on.
90+
*
91+
* Note that since collapsing only occurs when two tokens are on the same line, there are a few cases where non-intuitive
92+
* behavior can occur. For example, consider the following cases:
93+
*
94+
* foo(
95+
* ).
96+
* bar(
97+
* baz
98+
* )
99+
*
100+
* foo(
101+
* ).bar(
102+
* baz
103+
* )
104+
*
105+
* Based on the first example, it would seem that `bar` should be offset by 1 indent level from `foo`, and `baz`
106+
* should be offset by 1 indent level from `bar`. However, this is not correct, because it would result in `baz`
107+
* being indented by 2 indent levels in the second case (since `foo`, `bar`, and `baz` are all on separate lines, no
108+
* collapsing would occur).
109+
*
110+
* Instead, the correct way would be to offset `baz` by 1 level from `bar`, offset `bar` by 1 level from the `)`, and
111+
* offset the `)` by 0 levels from `foo`. This ensures that the offset between `bar` and the `)` are correctly collapsed
112+
* in the second case.
113+
*
114+
* @param token The token
115+
* @param fromToken The token that `token` should be offset from
116+
* @param offset The desired indent level
117+
*/
118+
public setDesiredOffset(
119+
token: TokenOrComment,
120+
fromToken: TokenOrComment | null,
121+
offset: number,
122+
): void {
123+
this.setDesiredOffsets(token.range, fromToken, offset);
124+
}
125+
126+
/**
127+
* Sets the desired offset of all tokens in a range
128+
* It's common for node listeners in this file to need to apply the same offset to a large, contiguous range of tokens.
129+
* Moreover, the offset of any given token is usually updated multiple times (roughly once for each node that contains
130+
* it). This means that the offset of each token is updated O(AST depth) times.
131+
* It would not be performant to store and update the offsets for each token independently, because the rule would end
132+
* up having a time complexity of O(number of tokens * AST depth), which is quite slow for large files.
133+
*
134+
* Instead, the offset tree is represented as a collection of contiguous offset ranges in a file. For example, the following
135+
* list could represent the state of the offset tree at a given point:
136+
*
137+
* * Tokens starting in the interval [0, 15) are aligned with the beginning of the file
138+
* * Tokens starting in the interval [15, 30) are offset by 1 indent level from the `bar` token
139+
* * Tokens starting in the interval [30, 43) are offset by 1 indent level from the `foo` token
140+
* * Tokens starting in the interval [43, 820) are offset by 2 indent levels from the `bar` token
141+
* * Tokens starting in the interval [820, ∞) are offset by 1 indent level from the `baz` token
142+
*
143+
* The `setDesiredOffsets` methods inserts ranges like the ones above. The third line above would be inserted by using:
144+
* `setDesiredOffsets([30, 43], fooToken, 1);`
145+
*
146+
* @param range A [start, end] pair. All tokens with range[0] <= token.start < range[1] will have the offset applied.
147+
* @param fromToken The token that this is offset from
148+
* @param offset The desired indent level
149+
* @param force `true` if this offset should not use the normal collapsing behavior. This should almost always be false.
150+
*/
151+
public setDesiredOffsets(
152+
range: [number, number],
153+
fromToken: TokenOrComment | null,
154+
offset: number = 0,
155+
force: boolean = false,
156+
): void {
157+
/*
158+
* Offset ranges are stored as a collection of nodes, where each node maps a numeric key to an offset
159+
* descriptor. The tree for the example above would have the following nodes:
160+
*
161+
* * key: 0, value: { offset: 0, from: null }
162+
* * key: 15, value: { offset: 1, from: barToken }
163+
* * key: 30, value: { offset: 1, from: fooToken }
164+
* * key: 43, value: { offset: 2, from: barToken }
165+
* * key: 820, value: { offset: 1, from: bazToken }
166+
*
167+
* To find the offset descriptor for any given token, one needs to find the node with the largest key
168+
* which is <= token.start. To make this operation fast, the nodes are stored in a balanced binary
169+
* search tree indexed by key.
170+
*/
171+
172+
const descriptorToInsert = { offset, from: fromToken, force };
173+
174+
const descriptorAfterRange = this.tree.findLe(range[1]).value;
175+
176+
const fromTokenIsInRange =
177+
fromToken &&
178+
fromToken.range[0] >= range[0] &&
179+
fromToken.range[1] <= range[1];
180+
// this has to be before the delete + insert below or else you'll get into a cycle
181+
const fromTokenDescriptor = fromTokenIsInRange
182+
? this.getOffsetDescriptor(fromToken!)
183+
: null;
184+
185+
// First, remove any existing nodes in the range from the tree.
186+
this.tree.deleteRange(range[0] + 1, range[1]);
187+
188+
// Insert a new node into the tree for this range
189+
this.tree.insert(range[0], descriptorToInsert);
190+
191+
/*
192+
* To avoid circular offset dependencies, keep the `fromToken` token mapped to whatever it was mapped to previously,
193+
* even if it's in the current range.
194+
*/
195+
if (fromTokenIsInRange) {
196+
this.tree.insert(fromToken!.range[0], fromTokenDescriptor!);
197+
this.tree.insert(fromToken!.range[1], descriptorToInsert);
198+
}
199+
200+
/*
201+
* To avoid modifying the offset of tokens after the range, insert another node to keep the offset of the following
202+
* tokens the same as it was before.
203+
*/
204+
this.tree.insert(range[1], descriptorAfterRange);
205+
}
206+
207+
/**
208+
* Gets the desired indent of a token
209+
* @returns The desired indent of the token
210+
*/
211+
public getDesiredIndent(token: TokenOrComment): string {
212+
if (!this.desiredIndentCache.has(token)) {
213+
if (this.ignoredTokens.has(token)) {
214+
/*
215+
* If the token is ignored, use the actual indent of the token as the desired indent.
216+
* This ensures that no errors are reported for this token.
217+
*/
218+
this.desiredIndentCache.set(
219+
token,
220+
this.tokenInfo.getTokenIndent(token),
221+
);
222+
} else if (this.lockedFirstTokens.has(token)) {
223+
const firstToken = this.lockedFirstTokens.get(token)!;
224+
225+
this.desiredIndentCache.set(
226+
token,
227+
228+
// (indentation for the first element's line)
229+
this.getDesiredIndent(
230+
this.tokenInfo.getFirstTokenOfLine(firstToken),
231+
) +
232+
// (space between the start of the first element's line and the first element)
233+
this.indentType.repeat(
234+
firstToken.loc.start.column -
235+
this.tokenInfo.getFirstTokenOfLine(firstToken).loc.start.column,
236+
),
237+
);
238+
} else {
239+
const offsetInfo = this.getOffsetDescriptor(token);
240+
const offset =
241+
offsetInfo.from &&
242+
offsetInfo.from.loc.start.line === token.loc.start.line &&
243+
!/^\s*?\n/u.test(token.value) &&
244+
!offsetInfo.force
245+
? 0
246+
: offsetInfo.offset * this.indentSize;
247+
248+
this.desiredIndentCache.set(
249+
token,
250+
(offsetInfo.from ? this.getDesiredIndent(offsetInfo.from) : '') +
251+
this.indentType.repeat(offset),
252+
);
253+
}
254+
}
255+
256+
return this.desiredIndentCache.get(token)!;
257+
}
258+
259+
/**
260+
* Ignores a token, preventing it from being reported.
261+
*/
262+
ignoreToken(token: TokenOrComment): void {
263+
if (this.tokenInfo.isFirstTokenOfLine(token)) {
264+
this.ignoredTokens.add(token);
265+
}
266+
}
267+
268+
/**
269+
* Gets the first token that the given token's indentation is dependent on
270+
* @returns The token that the given token depends on, or `null` if the given token is at the top level
271+
*/
272+
getFirstDependency(token: TSESTree.Token): TSESTree.Token | null;
273+
getFirstDependency(token: TokenOrComment): TokenOrComment | null;
274+
getFirstDependency(token: TokenOrComment): TokenOrComment | null {
275+
return this.getOffsetDescriptor(token).from;
276+
}
277+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy