Skip to content
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
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 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 docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ To help you convert HTML snippets to Elm code and help newcomers learn the synta

---

### __Go to symbol__

__Setting:__ `elmLand.feature.goToSymbol`

You can navigate symbols inside a file. This is helpful for quickly navigating among functions, values and types in a file. You can also navigate to any top-level declaration in any file, which is a quick way of getting to the right file.

![Go to symbol](./go-to-symbol.gif)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: Create this gif

Copy link
Contributor Author

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?)


---

## __Performance Table__

Elm's [editor plugins repo](https://github.com/elm/editor-plugins) recommends doing performance profiling to help others learn how different editors implement features, and also to help try to think of ways to bring down costs.
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@
"description": "Enable the 'HTML to Elm' feature",
"type": "boolean",
"default": true
},
"elmLand.feature.goToSymbol": {
"order": 7,
"description": "Enable the 'Go-to-symbol' feature",
"type": "boolean",
"default": true
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as JumpToDefinition from "./features/jump-to-definition"
import * as OfflinePackageDocs from "./features/offline-package-docs"
import * as TypeDrivenAutocomplete from './features/type-driven-autocomplete'
import * as HtmlToElm from './features/html-to-elm'
import * as GoToSymbol from "./features/go-to-symbol"

export async function activate(context: vscode.ExtensionContext) {
console.info("ACTIVATE")
Expand All @@ -32,6 +33,7 @@ export async function activate(context: vscode.ExtensionContext) {
OfflinePackageDocs.feature({ globalState, context })
TypeDrivenAutocomplete.feature({ globalState, context })
HtmlToElm.feature({ globalState, context })
GoToSymbol.feature({ globalState, context })
}

export function deactivate() {
Expand Down
319 changes: 319 additions & 0 deletions src/features/go-to-symbol.ts
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
}
}
Loading