-
Notifications
You must be signed in to change notification settings - Fork 2
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
Add go-to-symbol and open-symbol-by-name features #8
Open
lydell
wants to merge
21
commits into
elm-land:main
Choose a base branch
from
lydell:outline
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
c26ad80
Start adding go-to-symbol feature
lydell 6fac862
Complete impl
lydell 241b31e
Better UX
lydell 4b4ecd3
Add comment
lydell 78b3e97
Small simplification
lydell bc4a516
Fix comment
lydell cf344c7
Refactor to not re-init Elm app all the time
lydell b444e23
Parse in a worker
lydell 5a0d3d5
Use cancellation token
lydell 52aeacc
Queue
lydell e6e86e5
Massively simplify
lydell a82c824
Fix indentation
lydell 9237543
Implement workspace symbols
lydell 87a1c46
Implement cancel
lydell cb3fa0d
Filter
lydell bb48cf0
Update docs
lydell 15779bc
Split into two features
lydell 72e9687
Merge branch 'main' into outline
lydell b4b431e
Merge branch 'main' into outline
lydell e4e4455
Merge branch 'main' into outline
lydell 6e014b9
Update lists of features in docs
lydell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,319 @@ | ||
import * as vscode from 'vscode' | ||
import sharedLogic, { Feature } from './shared/logic' | ||
import * as ElmToAst from './shared/elm-to-ast' | ||
import * as ElmSyntax from './shared/elm-to-ast/elm-syntax' | ||
|
||
// This finds all top-level declarations (type, type alias, values, functions, ports) | ||
// with the following caveats: | ||
// - Odd spacing or inline comments can cause declarations to be missed. | ||
// - False positives might be found in multiline comments or multiline strings. | ||
// But it’s a very fast way of computing an important feature! | ||
// If there are a couple of false positives you can jump to, that’s not the end of the world. | ||
// For example, if you comment out some code using multiline comments, you’ll still be able | ||
// to jump to it, which is a bit unexpected but not super weird. | ||
// Parts come from here: https://github.com/rtfeldman/node-test-runner/blob/eedf853fc9b45afd73a0db72decebdb856a69771/lib/Parser.js#L229-L234 | ||
const topLevelDeclarationRegex = /\n|(?<=^type(?: +alias)? +)\p{Lu}[_\d\p{L}]*|^\p{Ll}[_\d\p{L}]*(?=.*=$)|^(?<=port +)\p{Ll}[_\d\p{L}]*(?= *:)/gmu | ||
|
||
type Fallback = { | ||
fsPath: string | ||
symbols: vscode.DocumentSymbol[] | ||
} | ||
|
||
export const feature: Feature = ({ context }) => { | ||
let fallback: Fallback | undefined = undefined | ||
|
||
context.subscriptions.push( | ||
vscode.languages.registerDocumentSymbolProvider('elm', { | ||
async provideDocumentSymbols(doc: vscode.TextDocument, token: vscode.CancellationToken) { | ||
// Allow user to disable this feature | ||
const isEnabled: boolean = vscode.workspace.getConfiguration('elmLand').feature.goToSymbol | ||
if (!isEnabled) return | ||
|
||
const start = Date.now() | ||
const text = doc.getText() | ||
const ast = await ElmToAst.run(text, token) | ||
|
||
let symbols: vscode.DocumentSymbol[] = [] | ||
if (ast) { | ||
symbols = ast.declarations.map(declarationToDocumentSymbol) | ||
fallback = { | ||
fsPath: doc.uri.fsPath, | ||
symbols, | ||
} | ||
} else if (fallback !== undefined && doc.uri.fsPath === fallback.fsPath) { | ||
// When you start editing code, it won’t have correct syntax straight away, | ||
// but VSCode will re-run this. If you have the Outline panel open in the sidebar, | ||
// it’s quite distracting if we return an empty list here – it will flash | ||
// between “no symbols” and all the symbols. So returning the symbols from last | ||
// time we got any improves the UX a little. Note: If you remove all text in the file, | ||
// the Outline view shows old stuff that isn’t available – until the file becomes | ||
// syntactically valid again – but I think it’s fine. | ||
symbols = fallback.symbols | ||
} | ||
|
||
console.info('provideDocumentSymbol', `${symbols.length} results in ${Date.now() - start}ms`) | ||
return symbols | ||
} | ||
}) | ||
) | ||
|
||
context.subscriptions.push( | ||
vscode.languages.registerWorkspaceSymbolProvider({ | ||
async provideWorkspaceSymbols(query: string, token: vscode.CancellationToken) { | ||
lydell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Allow user to disable this feature | ||
const isEnabled: boolean = vscode.workspace.getConfiguration('elmLand').feature.goToSymbol | ||
if (!isEnabled) return | ||
|
||
const start = Date.now() | ||
const symbols: vscode.SymbolInformation[] = [] | ||
const uris = await vscode.workspace.findFiles('**/*.elm', '**/{node_modules,elm-stuff}/**') | ||
|
||
for (const uri of uris) { | ||
const buffer = await vscode.workspace.fs.readFile(uri) | ||
if (token.isCancellationRequested) return | ||
const fileContents = Buffer.from(buffer).toString('utf8') | ||
let line = 0 | ||
let lineIndex = 0 | ||
for (const match of fileContents.matchAll(topLevelDeclarationRegex)) { | ||
const { 0: name, index: matchIndex = 0 } = match | ||
if (name === '\n') { | ||
line++ | ||
lineIndex = matchIndex | ||
} else if (nameMatchesQuery(name, query)) { | ||
const firstLetter = name.slice(0, 1) | ||
const character = matchIndex - lineIndex - 1 | ||
symbols.push( | ||
new vscode.SymbolInformation( | ||
name, | ||
firstLetter.toUpperCase() === firstLetter ? vscode.SymbolKind.Variable : vscode.SymbolKind.Function, | ||
'', | ||
new vscode.Location( | ||
uri, | ||
new vscode.Range( | ||
new vscode.Position(line, character), | ||
new vscode.Position(line, character + name.length) | ||
) | ||
) | ||
) | ||
) | ||
} | ||
} | ||
} | ||
|
||
console.info('provideWorkspaceSymbol', `${symbols.length} results in ${Date.now() - start}ms`) | ||
return symbols | ||
} | ||
}) | ||
) | ||
} | ||
|
||
// Checks that the characters of `query` appear in their order in a candidate symbol, | ||
// as documented here: https://code.visualstudio.com/api/references/vscode-api#WorkspaceSymbolProvider | ||
const nameMatchesQuery = (name: string, query: string): boolean => { | ||
const nameChars = Array.from(name) | ||
let nameIndex = 0 | ||
outer: for (const char of query) { | ||
for (; nameIndex < nameChars.length; nameIndex++) { | ||
if (nameChars[nameIndex] === char) continue outer | ||
} | ||
return false | ||
} | ||
return true | ||
} | ||
|
||
const declarationToDocumentSymbol = (declaration: ElmSyntax.Node<ElmSyntax.Declaration>): vscode.DocumentSymbol => { | ||
const symbol = ( | ||
name: ElmSyntax.Node<string>, | ||
symbolKind: vscode.SymbolKind, | ||
fullRange: ElmSyntax.Range = declaration.range | ||
) => new vscode.DocumentSymbol( | ||
name.value, | ||
'', | ||
symbolKind, | ||
sharedLogic.fromElmRange(fullRange), | ||
sharedLogic.fromElmRange(name.range) | ||
) | ||
|
||
const symbolWithChildren = ( | ||
name: ElmSyntax.Node<string>, | ||
symbolKind: vscode.SymbolKind, | ||
children: vscode.DocumentSymbol[] | ||
) => { | ||
const documentSymbol = symbol(name, symbolKind) | ||
documentSymbol.children = children | ||
return documentSymbol | ||
} | ||
|
||
switch (declaration.value.type) { | ||
case 'function': | ||
return symbolWithChildren( | ||
declaration.value.function.declaration.value.name, | ||
vscode.SymbolKind.Function, | ||
expressionToDocumentSymbols(declaration.value.function.declaration.value.expression.value) | ||
) | ||
|
||
case 'destructuring': | ||
return symbolWithChildren( | ||
{ | ||
value: patternToString(declaration.value.destructuring.pattern.value), | ||
range: declaration.value.destructuring.pattern.range | ||
}, | ||
vscode.SymbolKind.Function, | ||
expressionToDocumentSymbols(declaration.value.destructuring.expression.value) | ||
) | ||
|
||
case 'typeAlias': | ||
return symbol( | ||
declaration.value.typeAlias.name, | ||
typeAliasSymbolKind(declaration.value.typeAlias.typeAnnotation.value) | ||
) | ||
|
||
case 'typedecl': | ||
return symbolWithChildren( | ||
declaration.value.typedecl.name, | ||
vscode.SymbolKind.Enum, | ||
declaration.value.typedecl.constructors.map(constructor => | ||
symbol( | ||
constructor.value.name, | ||
vscode.SymbolKind.EnumMember, | ||
constructor.range | ||
) | ||
) | ||
) | ||
|
||
case 'port': | ||
return symbol( | ||
declaration.value.port.name, | ||
vscode.SymbolKind.Function | ||
) | ||
|
||
case 'infix': | ||
return symbol( | ||
declaration.value.infix.operator, | ||
vscode.SymbolKind.Operator | ||
) | ||
} | ||
} | ||
|
||
const expressionToDocumentSymbols = (expression: ElmSyntax.Expression): vscode.DocumentSymbol[] => { | ||
switch (expression.type) { | ||
case 'unit': | ||
return [] | ||
|
||
case 'application': | ||
return expression.application.flatMap(node => expressionToDocumentSymbols(node.value)) | ||
|
||
case 'operatorapplication': | ||
return [ | ||
...expressionToDocumentSymbols(expression.operatorapplication.left.value), | ||
...expressionToDocumentSymbols(expression.operatorapplication.right.value), | ||
] | ||
|
||
case 'functionOrValue': | ||
return [] | ||
|
||
case 'ifBlock': | ||
return [ | ||
...expressionToDocumentSymbols(expression.ifBlock.clause.value), | ||
...expressionToDocumentSymbols(expression.ifBlock.then.value), | ||
...expressionToDocumentSymbols(expression.ifBlock.else.value), | ||
] | ||
|
||
case 'prefixoperator': | ||
return [] | ||
|
||
case 'operator': | ||
return [] | ||
|
||
case 'hex': | ||
return [] | ||
|
||
case 'integer': | ||
return [] | ||
|
||
case 'float': | ||
return [] | ||
|
||
case 'negation': | ||
return expressionToDocumentSymbols(expression.negation.value) | ||
|
||
case 'literal': | ||
return [] | ||
|
||
case 'charLiteral': | ||
return [] | ||
|
||
case 'tupled': | ||
return expression.tupled.flatMap(node => expressionToDocumentSymbols(node.value)) | ||
|
||
case 'list': | ||
return expression.list.flatMap(node => expressionToDocumentSymbols(node.value)) | ||
|
||
case 'parenthesized': | ||
return expressionToDocumentSymbols(expression.parenthesized.value) | ||
|
||
case 'let': | ||
return [ | ||
...expression.let.declarations.map(declarationToDocumentSymbol), | ||
...expressionToDocumentSymbols(expression.let.expression.value), | ||
] | ||
|
||
case 'case': | ||
return [ | ||
...expressionToDocumentSymbols(expression.case.expression.value), | ||
...expression.case.cases.flatMap(node => expressionToDocumentSymbols(node.expression.value)), | ||
] | ||
|
||
case 'lambda': | ||
return expressionToDocumentSymbols(expression.lambda.expression.value) | ||
|
||
case 'recordAccess': | ||
return expressionToDocumentSymbols(expression.recordAccess.expression.value) | ||
|
||
case 'recordAccessFunction': | ||
return [] | ||
|
||
case 'record': | ||
return expression.record.flatMap(item => expressionToDocumentSymbols(item.value.expression.value)) | ||
|
||
case 'recordUpdate': | ||
return expression.recordUpdate.updates.flatMap(item => expressionToDocumentSymbols(item.value.expression.value)) | ||
|
||
case 'glsl': | ||
return [] | ||
} | ||
} | ||
|
||
const patternToString = (pattern: ElmSyntax.Pattern): string => { | ||
switch (pattern.type) { | ||
case 'string': return 'STRING' // should not happen | ||
case 'all': return '_' | ||
case 'unit': return '()' | ||
case 'char': return 'CHAR' // should not happen | ||
case 'hex': return 'HEX' // should not happen | ||
case 'int': return 'INT' // should not happen | ||
case 'float': return 'FLOAT' // should not happen | ||
case 'tuple': return `( ${pattern.tuple.value.map(value => patternToString(value.value)).join(', ')} )` | ||
case 'record': return `{ ${pattern.record.value.map(node => node.value).join(', ')} }` | ||
case 'uncons': return 'UNCONS' // should not happen | ||
case 'list': return 'LIST' // should not happen | ||
case 'var': return pattern.var.value | ||
case 'named': return pattern.named.patterns.map(node => patternToString(node.value)).join(' ') | ||
case 'as': return pattern.as.name.value | ||
case 'parentisized': return patternToString(pattern.parentisized.value.value) | ||
} | ||
} | ||
|
||
const typeAliasSymbolKind = (typeAnnotation: ElmSyntax.TypeAnnotation): vscode.SymbolKind => { | ||
switch (typeAnnotation.type) { | ||
// Note: In VSCode, TypeScript `type Foo =` gets `vscode.SymbolKind.Variable`. | ||
case 'function': return vscode.SymbolKind.Variable | ||
case 'generic': return vscode.SymbolKind.Variable | ||
case 'typed': return vscode.SymbolKind.Variable | ||
case 'unit': return vscode.SymbolKind.Variable | ||
case 'tupled': return vscode.SymbolKind.Variable | ||
// `vscode.SymbolKind.Object` gives a nice icon looking like this: {} | ||
case 'record': return vscode.SymbolKind.Object | ||
case 'genericRecord': return vscode.SymbolKind.Object | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO: Create this gif
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO: Update the performance tables further below
(should probably be done on the same computer as the other features?)