Merge pull request #829 from savetheclocktower/integrate-symbols-view-redux

Overhaul `symbols-view` (now a builtin package with data providers)
This commit is contained in:
Andrew Dupont 2024-01-07 15:34:04 -08:00 committed by GitHub
commit fd908ca0b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 10586 additions and 3 deletions

View File

@ -116,7 +116,9 @@ jobs:
- package: "spell-check"
- package: "status-bar"
- package: "styleguide"
# - package: "symbols-view"
- package: "symbol-provider-ctags"
- package: "symbol-provider-tree-sitter"
- package: "symbols-view"
- package: "tabs"
- package: "timecop"
- package: "tree-view"

View File

@ -166,7 +166,9 @@
"status-bar": "file:packages/status-bar",
"styleguide": "file:./packages/styleguide",
"superstring": "^2.4.4",
"symbols-view": "https://codeload.github.com/atom/symbols-view/legacy.tar.gz/refs/tags/v0.118.4",
"symbol-provider-ctags": "file:./packages/symbol-provider-ctags",
"symbol-provider-tree-sitter": "file:./packages/symbol-provider-tree-sitter",
"symbols-view": "file:./packages/symbols-view",
"tabs": "file:packages/tabs",
"temp": "0.9.4",
"text-buffer": "^13.18.6",
@ -237,7 +239,9 @@
"spell-check": "file:./packages/spell-check",
"status-bar": "file:./packages/status-bar",
"styleguide": "file:./packages/styleguide",
"symbols-view": "0.118.4",
"symbol-provider-ctags": "file:./packages/symbol-provider-ctags",
"symbol-provider-tree-sitter": "file:./packages/symbol-provider-tree-sitter",
"symbols-view": "file:./packages/symbols-view",
"tabs": "file:./packages/tabs",
"timecop": "file:./packages/timecop",
"tree-view": "file:./packages/tree-view",

View File

@ -0,0 +1,42 @@
module.exports = {
env: {
browser: true,
commonjs: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:node/recommended",
],
overrides: [],
parserOptions: {
ecmaVersion: "latest"
},
rules: {
"no-fallthrough": "off",
"no-case-declarations": "off",
"space-before-function-paren": ["error", {
anonymous: "always",
asyncArrow: "always",
named: "never"
}],
"node/no-unpublished-require": [
"error",
{
allowModules: ["electron"]
}
],
"node/no-missing-require": [
"error",
{
allowModules: ["atom"]
}
]
},
plugins: [
"jsdoc"
],
globals: {
atom: "writeable"
}
};

View File

@ -0,0 +1,21 @@
Copyright (c) 2023 Pulsar-Edit
Original work copyright (c) 2011-2022 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,24 @@
# symbol-provider-ctags package
Provides symbols to `symbols-view` via `ctags`.
This is the approach historically used by `symbols-view` now spun out into its own “provider” package among several.
This symbol provider will typically be used on non-Tree-sitter grammars, and possibly when performing a project-wide search. Symbol-based navigation on files with Tree-sitter grammars will typically be provided by `symbol-provider-tree-sitter`.
## Language support
This provider supports any language that is present in its config file, and detects any symbols that match the specified patterns. If your language isnt supported and you can help add support, well happily accept a pull request.
## Toggle file symbols
For the **Symbols View: Toggle File Symbols** command, `ctags` will scan the file on disk and emit its tag information to stdout, where it is read by this package. You dont need a `TAGS` file to do a symbol search within a single file.
## Toggle Project Symbols, Go To Declaration
These commands require a tags file, typically defined at `.tags`/`tags`/`.TAGS`/`TAGS` in the root of your project. This package cannot generate (or regenerate) your tags file, since it doesnt know which files to include. You can run `ctags` regularly on your own to generate this file. Consult [the documentation for Exuberant Ctags](https://ctags.sourceforge.net/ctags.html) for more information.
Once your tags file is present, these commands can be fulfilled by `symbol-provider-ctags`
* The **Symbols View: Toggle Project Symbols** command works like **Symbols View: Toggle File Symbols** described above, except itll show you symbols from the entire project.
* The **Symbols View: Go To Declaration** command works like **Symbols View: Toggle Project Symbols**, except the word under the cursor will be pre-filled in the search box, and a result will automatically be opened if it is the only result.

View File

@ -0,0 +1,222 @@
--langdef=CoffeeScript
--langmap=CoffeeScript:.coffee
--regex-CoffeeScript=/^[ \t]*(@?[a-zA-Z$_\.0-9]+)[ \t]*(=|\:)[ \t]*(\(.*\))?[ \t]*(-|=)>/\1/f,function/
--regex-CoffeeScript=/^[ \t]*([a-zA-Z$_0-9]+\:\:[a-zA-Z$_\.0-9]+)[ \t]*(=|\:)[ \t]*(\(.*\))?[ \t]*(-|=)>/\1/f,function/
--regex-CoffeeScript=/^[ \t]*describe[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-CoffeeScript=/^[ \t]*describe[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-CoffeeScript=/^[ \t]*it[ \t]"([^"]+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-CoffeeScript=/^[ \t]*it[ \t]'([^']+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-CoffeeScript=/^[ \t]*f+describe[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/focused\: \1/f,function/
--regex-CoffeeScript=/^[ \t]*f+describe[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/focused: \1/f,function/
--regex-CoffeeScript=/^[ \t]*f+it[ \t]"([^"]+)"[ \t]*,[ \t]+[-=]>/focused: \1/f,function/
--regex-CoffeeScript=/^[ \t]*f+it[ \t]'([^']+)'[ \t]*,[ \t]+[-=]>/focused: \1/f,function/
--regex-CoffeeScript=/^[ \t]*xdescribe[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/disabled\: \1/f,function/
--regex-CoffeeScript=/^[ \t]*xdescribe[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/disabled: \1/f,function/
--regex-CoffeeScript=/^[ \t]*xit[ \t]"([^"]+)"[ \t]*,[ \t]+[-=]>/disabled: \1/f,function/
--regex-CoffeeScript=/^[ \t]*xit[ \t]'([^']+)'[ \t]*,[ \t]+[-=]>/disabled: \1/f,function/
--regex-CoffeeScript=/^[ \t]*class[ \t]*([a-zA-Z$_\.0-9]+)[ \t]*/\1/f,function/
--langdef=ColdFusion
--langmap=ColdFusion:.cfc
--langmap=ColdFusion:+.cfm
--langmap=ColdFusion:+.cfml
--regex-ColdFusion=/(,|(;|^)[ \t]*(var|([A-Za-z_$][A-Za-z0-9_$.]*\.)*))[ \t]*([A-Za-z0-9_$]+)[ \t]*=[ \t]*function[ \t]*\(/\5/,function/
--regex-ColdFusion=/function[ \t]+([A-Za-z0-9_$]+)[ \t]*\([^)]*\)/\1/,function/
--regex-ColdFusion=/cffunction[ \t]+([A-Za-z0-9_$]+)[ \t]*\([^)]*\)/\1/,cffunction/
--regex-ColdFusion=/(,|^|\*\/)[ \t]*([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*:[ \t]*function[ \t]*\(/\2/,function/
--regex-ColdFusion=/(,|^|\*\/)[ \t]*(static[ \t]+)?(while|if|for|function|switch|with|([A-Za-z_$][A-Za-z0-9_$]+))[ \t]*\(.*\)[ \t]*\{/\2\4/,function/
--regex-ColdFusion=/(,|^|\*\/)[ \t]*get[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*\)[ \t]*\{/get \2/,function/
--regex-ColdFusion=/(,|^|\*\/)[ \t]*set[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*([A-Za-z_$][A-Za-z0-9_$]+)?[ \t]*\)[ \t]*\{/set \2/,function/
--regex-ColdFusion=/(,|^|\*\/)[ \t]*async[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*([A-Za-z_$].+)?[ \t]*\)[ \t]*\{/\2/,function/
--regex-ColdFusion=/component[ \t]+([A-Za-z0-9._$]+)[ \t]*/\1/c,component/
--regex-ColdFusion=/^[ \t]*given[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*given[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*story[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*story[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*feature[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*feature[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*when[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*when[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*then[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*then[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*describe[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*describe[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*it[ \t]"([^"]+)"[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*it[ \t]'([^']+)'[ \t]*,[ \t]+[-=]>/\1/f,function/
--regex-ColdFusion=/^[ \t]*xdescribe[ \t]"(.+)"[ \t]*,[ \t]+[-=]>/disabled\: \1/f,function/
--regex-ColdFusion=/^[ \t]*xdescribe[ \t]'(.+)'[ \t]*,[ \t]+[-=]>/disabled: \1/f,function/
--regex-ColdFusion=/^[ \t]*xit[ \t]"([^"]+)"[ \t]*,[ \t]+[-=]>/disabled: \1/f,function/
--regex-ColdFusion=/^[ \t]*xit[ \t]'([^']+)'[ \t]*,[ \t]+[-=]>/disabled: \1/f,function/
--langdef=Css
--langmap=Css:.css
--langmap=Css:+.less
--langmap=Css:+.scss
--regex-Css=/^[ \t]*(.+)[ \t]*\{/\1/f,function/
--regex-Css=/^[ \t]*(.+)[ \t]*,[ \t]*$/\1/f,function/
--regex-Css=/^[ \t]*[@$]([a-zA-Z$_][-a-zA-Z$_0-9]*)[ \t]*:/\1/f,function/
--langdef=Sass
--langmap=Sass:.sass
--regex-Sass=/^[ \t]*([#.]*[a-zA-Z_0-9]+)[ \t]*$/\1/f,function/
--langdef=Yaml
--langmap=Yaml:.yaml
--langmap=Yaml:+.yml
--regex-Yaml=/^[ \t]*([a-zA-Z_0-9 ]+)[ \t]*\:[ \t]*/\1/f,function/
--regex-Html=/^[ \t]*<([a-zA-Z]+)[ \t]*.*>/\1/f,function/
--langdef=Markdown
--langmap=Markdown:.md
--langmap=Markdown:+.markdown
--langmap=Markdown:+.mdown
--langmap=Markdown:+.mkd
--langmap=Markdown:+.mkdown
--langmap=Markdown:+.ron
--regex-Markdown=/^#+[ \t]*([^#]+)/\1/f,function/
--langdef=Json
--langmap=Json:.json
--regex-Json=/^[ \t]*"([^"]+)"[ \t]*\:/\1/f,function/
--langdef=Cson
--langmap=Cson:.cson
--langmap=Cson:+.gyp
--regex-Cson=/^[ \t]*'([^']+)'[ \t]*\:/\1/f,function/
--regex-Cson=/^[ \t]*"([^"]+)"[ \t]*\:/\1/f,function/
--regex-Cson=/^[ \t]*([^'"]+)[ \t]*\:/\1/f,function/
--langmap=C++:+.mm
--langmap=Ruby:+(Rakefile)
--langmap=Php:+.module
--langdef=Go
--langmap=Go:.go
--regex-Go=/func([ \t]+\([^)]+\))?[ \t]+([a-zA-Z0-9_]+)/\2/f,func/
--regex-Go=/var[ \t]+([a-zA-Z_][a-zA-Z0-9_]*)/\1/v,var/
--regex-Go=/type[ \t]+([a-zA-Z_][a-zA-Z0-9_]*)/\1/t,type/
--langdef=Capnp
--langmap=Capnp:.capnp
--regex-Capnp=/struct[ \t]+([A-Za-z]+)/\1/s,struct/
--regex-Capnp=/enum[ \t]+([A-Za-z]+)/\1/e,enum/
--regex-Capnp=/using[ \t]+([A-Za-z]+)[ \t]+=[ \t]+import/\1/u,using/
--regex-Capnp=/const[ \t]+([A-Za-z]+)/\1/c,const/
--langmap=perl:+.pod
--regex-perl=/with[ \t]+([^;]+)[ \t]*?;/\1/w,role,roles/
--regex-perl=/extends[ \t]+['"]([^'"]+)['"][ \t]*?;/\1/e,extends/
--regex-perl=/use[ \t]+base[ \t]+['"]([^'"]+)['"][ \t]*?;/\1/e,extends/
--regex-perl=/use[ \t]+parent[ \t]+['"]([^'"]+)['"][ \t]*?;/\1/e,extends/
--regex-perl=/Mojo::Base[ \t]+['"]([^'"]+)['"][ \t]*?;/\1/e,extends/
--regex-perl=/^[ \t]*?use[ \t]+([^;]+)[ \t]*?;/\1/u,use,uses/
--regex-perl=/^[ \t]*?require[ \t]+((\w|\:)+)/\1/r,require,requires/
--regex-perl=/^[ \t]*?has[ \t]+['"]?(\w+)['"]?/\1/a,attribute,attributes/
--regex-perl=/^[ \t]*?\*(\w+)[ \t]*?=/\1/a,alias,aliases/
--regex-perl=/->helper\([ \t]?['"]?(\w+)['"]?/\1/h,helper,helpers/
--regex-perl=/^[ \t]*?our[ \t]*?[\$@%](\w+)/\1/o,our,ours/
--regex-perl=/^\=head1[ \t]+(.+)/\1/p,pod,Plain Old Documentation/
--regex-perl=/^\=head2[ \t]+(.+)/-- \1/p,pod,Plain Old Documentation/
--regex-perl=/^\=head[3-5][ \t]+(.+)/---- \1/p,pod,Plain Old Documentation/
--regex-JavaScript=/(,|(;|^)[ \t]*(var|let|([A-Za-z_$][A-Za-z0-9_$.]*\.)*))[ \t]*([A-Za-z0-9_$]+)[ \t]*=[ \t]*function[ \t]*\(/\5/,function/
--regex-JavaScript=/function[ \t]+([A-Za-z0-9_$]+)[ \t]*\([^)]*\)/\1/,function/
--regex-JavaScript=/(,|^|\*\/)[ \t]*([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*:[ \t]*function[ \t]*\(/\2/,function/
--regex-JavaScript=/(,|^|\*\/)[ \t]*(static[ \t]+)?(while|if|for|function|switch|with|([A-Za-z_$][A-Za-z0-9_$]+))[ \t]*\(.*\)[ \t]*\{/\2\4/,function/
--regex-JavaScript=/(,|^|\*\/)[ \t]*get[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*\)[ \t]*\{/get \2/,function/
--regex-JavaScript=/(,|^|\*\/)[ \t]*set[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*([A-Za-z_$][A-Za-z0-9_$]+)?[ \t]*\)[ \t]*\{/set \2/,function/
--regex-JavaScript=/(,|^|\*\/)[ \t]*async[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*([A-Za-z_$].+)?[ \t]*\)[ \t]*\{/\2/,function/
--regex-JavaScript=/class[ \t]+([A-Za-z0-9._$]+)[ \t]*/\1/c,class/
--regex-JavaScript=/^[ \t]*describe\("([^"]+)"[ \t]*,/\1/f,function/
--regex-JavaScript=/^[ \t]*describe\('([^']+)'[ \t]*,/\1/f,function/
--regex-JavaScript=/^[ \t]*it\("([^"]+)"[ \t]*,/\1/f,function/
--regex-JavaScript=/^[ \t]*it\('([^']+)'[ \t]*,/\1/f,function/
--regex-JavaScript=/^[ \t]*f+describe\('([^']+)'[ \t]*,/focused: \1/f,function/
--regex-JavaScript=/^[ \t]*f+describe\("([^"]+)"[ \t]*,/focused: \1/f,function/
--regex-JavaScript=/^[ \t]*f+it\('([^']+)'[ \t]*,/focused: \1/f,function/
--regex-JavaScript=/^[ \t]*f+it\("([^"]+)"[ \t]*,/focused: \1/f,function/
--regex-JavaScript=/^[ \t]*xdescribe\('([^']+)'[ \t]*,/disabled: \1/f,function/
--regex-JavaScript=/^[ \t]*xdescribe\("([^"]+)"[ \t]*,/disabled: \1/f,function/
--regex-JavaScript=/^[ \t]*xit\('([^']+)'[ \t]*,/disabled: \1/f,function/
--regex-JavaScript=/^[ \t]*xit\("([^"]+)"[ \t]*,/disabled: \1/f,function/
--langdef=TypeScript
--langmap=TypeScript:.ts
--langmap=TypeScript:+.tsx
--regex-TypeScript=/(,|(;|^)[ \t]*(var|let|([A-Za-z_$][A-Za-z0-9_$.]*\.)*))[ \t]*([A-Za-z0-9_$]+)[ \t]*=[ \t]*function[ \t]*\(/\5/,function/
--regex-TypeScript=/function[ \t]+([A-Za-z0-9_$]+)[ \t]*\([^)]*\)/\1/,function/
--regex-TypeScript=/(,|^|\*\/)[ \t]*([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*:[ \t]*function[ \t]*\(/\2/,function/
--regex-TypeScript=/(,|^|\*\/)[ \t]*(static[ \t]+)?(while|if|for|function|switch|with|([A-Za-z_$][A-Za-z0-9_$]+))[ \t]*\(.*\)[ \t]*\{/\2\4/,function/
--regex-TypeScript=/(,|^|\*\/)[ \t]*get[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*\)[ \t]*\{/get \2/,function/
--regex-TypeScript=/(,|^|\*\/)[ \t]*set[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*([A-Za-z_$][A-Za-z0-9_$]+)?[ \t]*\)[ \t]*\{/set \2/,function/
--regex-TypeScript=/(,|^|\*\/)[ \t]*async[ \t]+([A-Za-z_$][A-Za-z0-9_$]+)[ \t]*\([ \t]*([A-Za-z_$].+)?[ \t]*\)[ \t]*\{/\2/,function/
--regex-TypeScript=/class[ \t]+([A-Za-z0-9._$]+)[ \t]*/\1/c,class/
--regex-TypeScript=/^[ \t]*describe\("([^"]+)"[ \t]*,/\1/f,function/
--regex-TypeScript=/^[ \t]*describe\('([^']+)'[ \t]*,/\1/f,function/
--regex-TypeScript=/^[ \t]*it\("([^"]+)"[ \t]*,/\1/f,function/
--regex-TypeScript=/^[ \t]*it\('([^']+)'[ \t]*,/\1/f,function/
--regex-TypeScript=/^[ \t]*f+describe\('([^']+)'[ \t]*,/focused: \1/f,function/
--regex-TypeScript=/^[ \t]*f+describe\("([^"]+)"[ \t]*,/focused: \1/f,function/
--regex-TypeScript=/^[ \t]*f+it\('([^']+)'[ \t]*,/focused: \1/f,function/
--regex-TypeScript=/^[ \t]*f+it\("([^"]+)"[ \t]*,/focused: \1/f,function/
--regex-TypeScript=/^[ \t]*xdescribe\('([^']+)'[ \t]*,/disabled: \1/f,function/
--regex-TypeScript=/^[ \t]*xdescribe\("([^"]+)"[ \t]*,/disabled: \1/f,function/
--regex-TypeScript=/^[ \t]*xit\('([^']+)'[ \t]*,/disabled: \1/f,function/
--regex-TypeScript=/^[ \t]*xit\("([^"]+)"[ \t]*,/disabled: \1/f,function/
--langdef=haxe
--langmap=haxe:.hx
--regex-haxe=/^package[ \t]+([A-Za-z0-9_.]+)/\1/p,package/
--regex-haxe=/^[ \t]*[(@:macro|private|public|static|override|inline|dynamic)( \t)]*function[ \t]+([A-Za-z0-9_]+)/\1/f,function/
--regex-haxe=/^[ \t]*([private|public|static|protected|inline][ \t]*)+var[ \t]+([A-Za-z0-9_]+)/\2/v,variable/
--regex-haxe=/^[ \t]*package[ \t]*([A-Za-z0-9_]+)/\1/p,package/
--regex-haxe=/^[ \t]*(extern[ \t]*|@:native\([^)]*\)[ \t]*)*class[ \t]+([A-Za-z0-9_]+)[ \t]*[^\{]*/\2/c,class/
--regex-haxe=/^[ \t]*(extern[ \t]+)?interface[ \t]+([A-Za-z0-9_]+)/\2/i,interface/
--regex-haxe=/^[ \t]*typedef[ \t]+([A-Za-z0-9_]+)/\1/t,typedef/
--regex-haxe=/^[ \t]*enum[ \t]+([A-Za-z0-9_]+)/\1/t,typedef/
--regex-haxe=/^[ \t]*+([A-Za-z0-9_]+)(;|\([^)]*:[^)]*\))/\1/t,enum_field/
--langdef=Elixir
--langmap=Elixir:.ex.exs
--regex-Elixir=/^[ \t]*def(p?)[ \t]+([a-z_][a-zA-Z0-9_?!]*)/\2/f,functions,functions (def ...)/
--regex-Elixir=/^[ \t]*defcallback[ \t]+([a-z_][a-zA-Z0-9_?!]*)/\1/c,callbacks,callbacks (defcallback ...)/
--regex-Elixir=/^[ \t]*defdelegate[ \t]+([a-z_][a-zA-Z0-9_?!]*)/\1/d,delegates,delegates (defdelegate ...)/
--regex-Elixir=/^[ \t]*defexception[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/e,exceptions,exceptions (defexception ...)/
--regex-Elixir=/^[ \t]*defimpl[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/i,implementations,implementations (defimpl ...)/
--regex-Elixir=/^[ \t]*defmacro(p?)[ \t]+([a-z_][a-zA-Z0-9_?!]*)\(/\2/a,macros,macros (defmacro ...)/
--regex-Elixir=/^[ \t]*defmacro(p?)[ \t]+([a-zA-Z0-9_?!]+)?[ \t]+([^ \tA-Za-z0-9_]+)[ \t]*[a-zA-Z0-9_!?!]/\3/o,operators,operators (e.g. "defmacro a <<< b")/
--regex-Elixir=/^[ \t]*defmodule[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/m,modules,modules (defmodule ...)/
--regex-Elixir=/^[ \t]*defprotocol[ \t]+([A-Z][a-zA-Z0-9_]*\.)*([A-Z][a-zA-Z0-9_?!]*)/\2/p,protocols,protocols (defprotocol...)/
--regex-Elixir=/^[ \t]*Record\.defrecord[ \t]+:([a-zA-Z0-9_]+)/\1/r,records,records (defrecord...)/
--langdef=Nim
--langmap=Nim:.nim
--regex-Nim=/^[\t\s]*proc\s+([_A-Za-z0-9]+)\**(\[\w+(\:\s+\w+)?\])?\s*\(/\1/f,function/
--regex-Nim=/^[\t\s]*iterator\s+([_A-Za-z0-9]+)\**(\[\w+(\:\s+\w+)?\])?\s*\(/\1/i,iterator/
--regex-Nim=/^[\t\s]*macro\s+([_A-Za-z0-9]+)\**(\[\w+(\:\s+\w+)?\])?\s*\(/\1/m,macro/
--regex-Nim=/^[\t\s]*method\s+([_A-Za-z0-9]+)\**(\[\w+(\:\s+\w+)?\])?\s*\(/\1/h,method/
--regex-Nim=/^[\t\s]*template\s+([_A-Za-z0-9]+)\**(\[\w+(\:\s+\w+)?\])?\s*\(/\1/t,generics/
--regex-Nim=/^[\t\s]*converter\s+([_A-Za-z0-9]+)\**(\[\w+(\:\s+\w+)?\])?\s*\(/\1/c,converter/
--langdef=Fountain
--langmap=Fountain:.fountain
--langmap=Fountain:+.ftn
--regex-Fountain=/^(([iI][nN][tT]|[eE][xX][tT]|[^\w][eE][sS][tT]|\.|[iI]\.?\/[eE]\.?)([^\n]+))/\1/f,function/
--langdef=Julia
--langmap=Julia:.jl
--regex-Julia=/^[ \t]*(function|macro|abstract|type|typealias|immutable)[ \t]+([^ \t({[]+).*$/\2/f,function/
--regex-Julia=/^[ \t]*(([^@#$ \t({[]+)|\(([^@#$ \t({[]+)\)|\((\$)\))[ \t]*(\{.*\})?[ \t]*\([^#]*\)[ \t]*=([^=].*$|$)/\2\3\4/f,function/
--langdef=Latex
--langmap=latex:.tex
--regex-latex=/\\label\{([^}]*)\}/\1/l,label/
--regex-latex=/\\section\{([^}]*)\}/\1/s,section/
--regex-latex=/\\subsection\{([^}]*)\}/\1/t,subsection/
--regex-latex=/\\subsubsection\{([^}]*)\}/\1/u,subsubsection/
--regex-latex=/\\section\*\{([^}]*)\}/\1/s,section/
--regex-latex=/\\subsection\*\{([^}]*)\}/\1/t,subsection/
--regex-latex=/\\subsubsection\*\{([^}]*)\}/\1/u,subsubsection/

View File

@ -0,0 +1,284 @@
const {
BufferedProcess,
CompositeDisposable,
File,
Point
} = require('atom');
const TagReader = require('./tag-reader');
const getTagsFile = require('./get-tags-file');
const fs = require('fs-plus');
const path = require('path');
class CtagsProvider {
constructor() {
this.watchTagsFiles();
this.loadSymbols();
this.packageName = 'symbol-provider-ctags';
this.name = 'ctags';
this.isExclusive = true;
}
destroy() {
this.loadTask?.terminate?.();
this.unwatchTagsFiles();
}
canProvideSymbols(meta) {
let { editor } = meta;
// Can't provide symbols unless a file is saved.
if (editor.getPath() === undefined) return 0;
// We start off with a score less than 1 because `ctags` should always lose
// out when it's competing against the Tree-sitter provider.
let score = 0.99;
// If the file isn't saved on disk, this provider's results may be
// inaccurate, so it's a less attractive candidate.
if (editor.isModified()) score -= 0.1;
return score;
}
getEditor() {
return atom.workspace.getActiveTextEditor();
}
getPath() {
if (this.getEditor()) {
return this.getEditor().getPath();
}
return undefined;
}
getScopeName() {
if (this.getEditor() && this.getEditor().getGrammar()) {
return this.getEditor().getGrammar().scopeName;
}
return undefined;
}
getPackageRoot() {
const {resourcePath} = atom.getLoadSettings();
const currentFileWasRequiredFromSnapshot = !fs.isAbsolute(__dirname);
const packageRoot = currentFileWasRequiredFromSnapshot
? path.join(resourcePath, 'node_modules', 'symbols-view')
: path.resolve(__dirname, '..');
if (path.extname(resourcePath) === '.asar' && packageRoot.indexOf(resourcePath) === 0) {
return path.join(`${resourcePath}.unpacked`, 'node_modules', 'symbols-view');
} else {
return packageRoot;
}
}
parseTagLine(line) {
let sections = line.split('\t');
if (sections.length > 3) {
return {
position: new Point(parseInt(sections[2], 10) - 1),
name: sections[0],
};
}
return null;
}
validateTags(tags) {
let symbols = [];
for (let tag of tags) {
let tagFilePath = path.join(tag.directory, tag.file);
if (!fs.existsSync(tagFilePath)) {
continue;
}
symbols.push(this.interpretTag(tag));
}
return symbols;
}
interpretTag(tag) {
return {
directory: tag.directory,
file: tag.file,
name: tag.name,
position: this.getTagPosition(tag)
};
}
getTagPosition(tag) {
if (!tag) {
return undefined;
}
if (tag.lineNumber) {
return new Point(tag.lineNumber - 1, 0);
}
// Remove leading /^ and trailing $/
if (!tag.pattern) {
return undefined;
}
const pattern = tag.pattern.replace(/(^\/\^)|(\$\/$)/g, '').trim();
if (!pattern) {
return undefined;
}
const file = path.join(tag.directory, tag.file);
if (!fs.isFileSync(file)) {
return undefined;
}
const iterable = fs.readFileSync(file, 'utf8').split('\n');
for (let index = 0; index < iterable.length; index++) {
let line = iterable[index];
if (pattern === line.trim()) {
return new Point(index, 0);
}
}
return undefined;
}
watchTagsFiles() {
this.unwatchTagsFiles();
this.tagsFileSubscriptions = new CompositeDisposable();
let reloadTags = () => {
this.reloadTags = true;
this.watchTagsFiles();
};
for (let projectPath of atom.project.getPaths()) {
let tagsFilePath = getTagsFile(projectPath);
if (!tagsFilePath) continue;
let tagsFile = new File(tagsFilePath);
this.tagsFileSubscriptions.add(
tagsFile.onDidChange(reloadTags),
tagsFile.onDidDelete(reloadTags),
tagsFile.onDidRename(reloadTags)
);
}
}
unwatchTagsFiles() {
this.tagsFileSubscriptions?.dispose();
}
getLanguage(editor) {
if (['.cson', '.gyp'].includes(path.extname(this.path))) {
return 'Cson';
}
let scopeName = this.getScopeName(editor);
switch (scopeName) {
case 'source.c': return 'C';
case 'source.cpp': return 'C++';
case 'source.clojure': return 'Lisp';
case 'source.capnp': return 'Capnp';
case 'source.cfscript': return 'ColdFusion';
case 'source.cfscript.embedded': return 'ColdFusion';
case 'source.coffee': return 'CoffeeScript';
case 'source.css': return 'Css';
case 'source.css.less': return 'Css';
case 'source.css.scss': return 'Css';
case 'source.elixir': return 'Elixir';
case 'source.fountain': return 'Fountain';
case 'source.gfm': return 'Markdown';
case 'source.go': return 'Go';
case 'source.java': return 'Java';
case 'source.js': return 'JavaScript';
case 'source.js.jsx': return 'JavaScript';
case 'source.jsx': return 'JavaScript';
case 'source.json': return 'Json';
case 'source.julia': return 'Julia';
case 'source.makefile': return 'Make';
case 'source.objc': return 'C';
case 'source.objcpp': return 'C++';
case 'source.python': return 'Python';
case 'source.ruby': return 'Ruby';
case 'source.sass': return 'Sass';
case 'source.ts': return 'TypeScript';
case 'source.ts.tsx': return 'TypeScript';
case 'source.yaml': return 'Yaml';
case 'text.html': return 'Html';
case 'text.html.php': return 'Php';
case 'text.tex.latex': return 'Latex';
case 'text.html.cfml': return 'ColdFusion';
}
// TODO: Fall back to a grammar registry lookup?
return undefined;
}
loadSymbols() {
return new Promise(resolve => {
this.loadTask = TagReader.getAllTags(resolve);
});
}
getSymbols(meta) {
if (meta.type === 'project') {
return this.getSymbolsInProject(meta);
} else if (meta.type === 'project-find') {
return this.findDefinitionsInProject(meta);
}
let { editor } = meta;
let tags = {};
let packageRoot = this.getPackageRoot();
let command = path.join(packageRoot, 'vendor', `ctags-${process.platform}`);
let defaultCtagsFile = path.join(packageRoot, 'lib', 'ctags-config');
const args = [
`--options=${defaultCtagsFile}`,
`--fields=+KS`
];
if (atom.config.get('symbol-provider-ctags.useEditorGrammarAsCtagsLanguage')) {
let language = this.getLanguage(editor);
if (language) {
args.push(`--language-force=${language}`);
}
}
args.push('-nf', '-', editor.getPath());
return new Promise(resolve => {
let result, tag;
return new BufferedProcess({
command,
args,
stdout: lines => {
result = [];
for (let line of lines.split('\n')) {
let item;
tag = this.parseTagLine(line);
if (tag) {
item = tags[tag.position.row] ?
tags[tag.position.row] :
(tags[tag.position.row] = tag);
}
result.push(item);
}
return result;
},
stderr: () => {},
exit: () => {
return resolve(Object.values(tags));
}
})
});
}
async getSymbolsInProject() {
return TagReader.getAllTags();
}
async findDefinitionsInProject(meta) {
let tags = await TagReader.find(meta.editor);
return this.validateTags(tags);
}
}
module.exports = CtagsProvider;

View File

@ -0,0 +1,22 @@
const fs = require('fs-plus');
const path = require('path');
const FILES = [
'TAGS',
'tags',
'.TAGS',
'.tags',
path.join('.git', 'tags'),
path.join('.git', 'TAGS')
];
function getTagsFile(directoryPath) {
if (!directoryPath) return;
for (let file of FILES) {
let tagsFile = path.join(directoryPath, file);
if (fs.isFileSync(tagsFile)) return tagsFile;
}
}
module.exports = getTagsFile;

View File

@ -0,0 +1,26 @@
/* global emit */
const async = require('async');
const ctags = require('ctags');
const getTagsFile = require('./get-tags-file');
module.exports = function loadTags(directoryPaths) {
// TODO: I tried to remove the dependency on the `async` package but failed
// spectacularly. I should try again at some point.
return async.each(
directoryPaths,
(directoryPath, done) => {
let tagsFilePath = getTagsFile(directoryPath);
if (!tagsFilePath) { return done(); }
let stream = ctags.createReadStream(tagsFilePath);
stream.on('data', function(tags) {
for (const tag of Array.from(tags)) { tag.directory = directoryPath; }
return emit('tags', tags);
});
stream.on('end', done);
return stream.on('error', done);
}
, this.async()
);
};

View File

@ -0,0 +1,16 @@
const CtagsProvider = require('./ctags-provider');
module.exports = {
activate() {
this.provider = new CtagsProvider();
},
deactivate() {
this.provider?.destroy?.();
},
provideSymbols() {
return this.provider;
}
};

View File

@ -0,0 +1,131 @@
const { Point, Task } = require('atom');
const util = require('util');
const ctags = require('ctags');
const getTagsFile = require('./get-tags-file');
const _ = require('underscore-plus');
let handlerPath = require.resolve('./load-tags-handler');
let findTagsWithPromise = util.promisify(ctags.findTags).bind(ctags);
function wordAtCursor(text, cursorIndex, wordSeparator, noStripBefore) {
const beforeCursor = text.slice(0, cursorIndex);
const afterCursor = text.slice(cursorIndex);
const beforeCursorWordBegins = noStripBefore ? 0 :
beforeCursor.lastIndexOf(wordSeparator) + 1;
let afterCursorWordEnds = afterCursor.indexOf(wordSeparator);
if (afterCursorWordEnds === -1) {
afterCursorWordEnds = afterCursor.length;
}
return beforeCursor.slice(beforeCursorWordBegins) +
afterCursor.slice(0, afterCursorWordEnds);
}
module.exports = {
async find(editor) {
let symbol = editor.getSelectedText();
let symbols = [];
if (symbol) symbols.push(symbol);
if (!symbols.length) {
let nonWordCharacters;
let cursor = editor.getLastCursor();
let cursorPosition = cursor.getBufferPosition();
let scope = cursor.getScopeDescriptor();
const rubyScopes = scope.getScopesArray().filter(s => /^source\.ruby($|\.)/.test(s));
let hasRubyScope = rubyScopes.length > 0;
let wordRegExp = cursor.wordRegExp();
if (hasRubyScope) {
nonWordCharacters = atom.config.get('editor.nonWordCharacters', { scope });
nonWordCharacters = nonWordCharacters.replace(/:/g, '');
wordRegExp = new RegExp(
`[^\\s${_.escapeRegExp(nonWordCharacters)}]+([!?]|\\s*=>?)?|[<=>]+`,
'g'
);
}
let addSymbol = (symbol) => {
if (hasRubyScope) {
// Normalize assignment syntax
if (/\s+=?$/.test(symbol)) {
symbols.push(symbol.replace(/\s+=$/, '='));
}
// Strip away assignment & hashrocket syntax
symbols.push(symbol.replace(/\s+=>?$/, ''));
} else {
symbols.push(symbol);
}
};
// Can't use `getCurrentWordBufferRange` here because we want to select
// the last match of the potential 2 matches under cursor.
editor.scanInBufferRange(wordRegExp, cursor.getCurrentLineBufferRange(), ({range, match}) => {
if (range.containsPoint(cursorPosition)) {
symbol = match[0];
if (rubyScopes.length && symbol.indexOf(':') > -1) {
const cursorWithinSymbol = cursorPosition.column - range.start.column;
// Add fully-qualified ruby constant up until the cursor position
addSymbol(wordAtCursor(symbol, cursorWithinSymbol, ':', true));
// Additionally, also look up the bare word under cursor
addSymbol(wordAtCursor(symbol, cursorWithinSymbol, ':'));
} else {
addSymbol(symbol);
}
}
});
}
if (symbols.length === 0) return [];
let results = [];
for (let projectPath of atom.project.getPaths()) {
let tagsFile = getTagsFile(projectPath);
if (!tagsFile) continue;
for (let symbol of symbols) {
let tags;
try {
tags = await findTagsWithPromise(tagsFile, symbol);
if (!tags) tags = [];
} catch (err) {
continue;
}
if (tags.length === 0) continue;
for (let tag of [...tags]) {
tag.directory = projectPath;
}
results.push(...tags);
}
}
return results;
},
getAllTags() {
let projectTags = [];
return new Promise(resolve => {
let task = Task.once(
handlerPath,
atom.project.getPaths(),
() => {
resolve(projectTags);
}
);
task.on('tags', tags => {
for (let tag of tags) {
if (tag.lineNumber) {
tag.position = new Point(tag.lineNumber - 1, 0);
}
}
projectTags.push(...tags);
});
});
},
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
{
"name": "symbol-provider-ctags",
"main": "./lib/main",
"version": "1.0.0",
"description": "Provides symbols to symbols-view via ctags",
"repository": "https://github.com/pulsar-edit/pulsar",
"license": "MIT",
"engines": {
"atom": ">=1.0.0 <2.0.0",
"node": ">=14"
},
"providedServices": {
"symbol.provider": {
"description": "Allows external sources to suggest symbols for a given file or project.",
"versions": {
"1.0.0": "provideSymbols"
}
}
},
"configSchema": {
"useEditorGrammarAsCtagsLanguage": {
"default": true,
"type": "boolean",
"description": "Force `ctags` to use the name of the current file's language in Pulsar when generating tags. By default, `ctags` automatically selects the language of a source file, ignoring those files whose language cannot be determined. This option forces the specified language to be used instead of automatically selecting the language based upon its extension."
}
},
"dependencies": {
"async": "^0.2.6",
"fs-plus": "^3.1.1",
"ctags": "^3.1.0"
},
"devDependencies": {
"eslint": "^8.39.0",
"temp": "^0.9.4"
}
}

View File

@ -0,0 +1,13 @@
module.exports = {
env: { jasmine: true },
globals: {
waitsForPromise: true
},
rules: {
"node/no-unpublished-require": "off",
"node/no-extraneous-require": "off",
"no-unused-vars": "off",
"no-empty": "off",
"no-constant-condition": "off"
}
};

View File

@ -0,0 +1,6 @@
#define UNUSED(x) (void)(x)
static void f(int x)
{
UNUSED(x);
}

View File

@ -0,0 +1,8 @@
!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/
!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/
!_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/
!_TAG_PROGRAM_NAME Exuberant Ctags //
!_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/
!_TAG_PROGRAM_VERSION 5.9~svn20110310 //
UNUSED sample.c 1;" d file:
f sample.c /^static void f(int x)$/;" f file:

View File

@ -0,0 +1,3 @@
tagged.js
sample.js
other-file.js

View File

@ -0,0 +1,6 @@
// Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
// tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
// consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
// cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
// non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

View File

@ -0,0 +1,29 @@
// Another file for symbols to exist in. Used for project search.
var quicksort = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};
var quicksort2 = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,13 @@
var quicksort = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,11 @@
var thisIsCrazy = true;
function callMeMaybe() {
return "here's my number";
}
var iJustMetYou = callMeMaybe();
function duplicate() {
return true;
}

View File

@ -0,0 +1,10 @@
!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/
!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/
!_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/
!_TAG_PROGRAM_NAME Exuberant Ctags //
!_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/
!_TAG_PROGRAM_VERSION 5.8 //
callMeMaybe tagged.js /^function callMeMaybe() {$/;" f
duplicate tagged-duplicate.js /^function duplicate() {$/;" f
duplicate tagged.js /^function duplicate() {$/;" f
thisIsCrazy tagged.js /^var thisIsCrazy = true;$/;" v

View File

@ -0,0 +1,33 @@
module A::Foo
B = 'b'
def bar!
end
def bar?
end
def baz
end
def baz=(*)
end
end
if bar?
baz
bar!
elsif !bar!
baz= 1
baz = 2
Foo = 3
{ :baz => 4 }
A::Foo::B
C::Foo::B
D::Foo::E
end
module D::Foo
end

View File

@ -0,0 +1,15 @@
!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/
!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/
!_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/
!_TAG_PROGRAM_NAME Exuberant Ctags //
!_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/
!_TAG_PROGRAM_VERSION 5.8 //
A::Foo file1.rb /^module A::Foo$/;" m
A::Foo::B file1.rb /^ B = 'b'$/;" C
B file1.rb /^ B = 'b'$/;" C
D::Foo file1.rb /^module D::Foo$/;" m
Foo file1.rb /^module A::Foo$/;" m
bar! file1.rb /^ def bar!$/;" f class:Foo
bar? file1.rb /^ def bar?$/;" f class:Foo
baz file1.rb /^ def baz$/;" f class:Foo
baz= file1.rb /^ def baz=(*)$/;" f class:Foo

View File

@ -0,0 +1,297 @@
const path = require('path');
const fs = require('fs-plus');
const temp = require('temp');
const CTagsProvider = require('../lib/ctags-provider');
function getEditor() {
return atom.workspace.getActiveTextEditor();
}
async function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function getProjectSymbols(provider, editor) {
let symbols = await provider.getSymbols({
type: 'project',
editor,
paths: atom.project.getPaths()
});
return symbols;
}
async function findDeclarationInProject(provider, editor) {
let symbols = await provider.getSymbols({
type: 'project-find',
editor,
paths: atom.project.getPaths(),
word: editor.getWordUnderCursor()
});
return symbols;
}
describe('CTagsProvider', () => {
let provider, directory, editor;
beforeEach(() => {
jasmine.unspy(global, 'setTimeout');
jasmine.unspy(Date, 'now');
provider = new CTagsProvider();
atom.project.setPaths([
temp.mkdirSync('other-dir-'),
temp.mkdirSync('atom-symbols-view-')
]);
directory = atom.project.getDirectories()[1];
fs.copySync(
path.join(__dirname, 'fixtures', 'js'),
atom.project.getPaths()[1]
);
});
describe('when tags can be generated for a file', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
});
it('provides all JavaScript functions', async () => {
let symbols = await provider.getSymbols({
type: 'file',
editor
});
expect(symbols[0].name).toBe('quicksort');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('quicksort.sort');
expect(symbols[1].position.row).toEqual(1);
});
});
describe('when the buffer is new and unsaved', () => {
beforeEach(async () => {
await atom.workspace.open();
editor = getEditor();
});
it('does not try to provide symbols', () => {
let meta = { type: 'file', editor };
expect(provider.canProvideSymbols(meta)).toBe(0);
});
});
describe('when the buffer is modified', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
});
it('returns a lower match score', () => {
editor.insertText("\n");
let meta = { type: 'file', editor };
expect(provider.canProvideSymbols(meta)).toBe(0.89);
});
});
describe('when no tags can be generated for a file', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('no-symbols.js'));
editor = getEditor();
});
it('returns an empty array', async () => {
let symbols = await provider.getSymbols({ type: 'file', editor });
expect(Array.isArray(symbols)).toBe(true);
expect(symbols.length).toBe(0);
});
});
describe('go to declaration', () => {
it("returns nothing when no declaration is found", async () => {
await atom.workspace.open(directory.resolve('tagged.js'));
editor = getEditor();
editor.setCursorBufferPosition([0, 2]);
let symbols = await provider.getSymbols({
type: 'project-find',
editor,
paths: atom.project.getPaths(),
word: editor.getWordUnderCursor()
});
expect(symbols.length).toBe(0);
});
it("returns one result when there is a single matching declaration", async () => {
await atom.workspace.open(directory.resolve('tagged.js'));
editor = getEditor();
editor.setCursorBufferPosition([6, 24]);
let symbols = await provider.getSymbols({
type: 'project-find',
editor,
paths: atom.project.getPaths(),
word: editor.getWordUnderCursor()
});
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([2, 0]);
});
it("correctly identifies the tag for a C preprocessor macro", async () => {
atom.project.setPaths([temp.mkdirSync('atom-symbols-view-c-')]);
fs.copySync(
path.join(__dirname, 'fixtures', 'c'),
atom.project.getPaths()[0]
);
await atom.packages.activatePackage('language-c');
await atom.workspace.open('sample.c');
editor = getEditor();
editor.setCursorBufferPosition([4, 4]);
let symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([0, 0]);
});
it('ignores results that reference nonexistent files', async () => {
await atom.workspace.open(directory.resolve('tagged.js'));
editor = getEditor();
editor.setCursorBufferPosition([8, 14]);
let symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([8, 0]);
});
it('includes ? and ! characters in ruby symbols', async () => {
atom.project.setPaths([temp.mkdirSync('atom-symbols-view-ruby-')]);
fs.copySync(
path.join(__dirname, 'fixtures', 'ruby'),
atom.project.getPaths()[0]
);
await atom.packages.activatePackage('language-ruby');
await atom.workspace.open('file1.rb');
let symbols;
editor = getEditor();
editor.setCursorBufferPosition([18, 4]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([7, 0]);
editor.setCursorBufferPosition([19, 2]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([11, 0]);
editor.setCursorBufferPosition([20, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([3, 0]);
editor.setCursorBufferPosition([21, 7]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([3, 0]);
});
it('understands assignment ruby method definitions', async () => {
atom.project.setPaths([temp.mkdirSync('atom-symbols-view-ruby-')]);
fs.copySync(
path.join(__dirname, 'fixtures', 'ruby'),
atom.project.getPaths()[0]
);
await atom.packages.activatePackage('language-ruby');
await atom.workspace.open('file1.rb');
let symbols;
editor = getEditor();
editor.setCursorBufferPosition([22, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([14, 0]);
editor.setCursorBufferPosition([23, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(2);
expect(symbols[0].position).toEqual([14, 0]);
editor.setCursorBufferPosition([24, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([0, 0]);
editor.setCursorBufferPosition([25, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([11, 0]);
});
it('understands fully qualified ruby constant definitions', async () => {
atom.project.setPaths([temp.mkdirSync('atom-symbols-view-ruby-')]);
fs.copySync(
path.join(__dirname, 'fixtures', 'ruby'),
atom.project.getPaths()[0]
);
await atom.packages.activatePackage('language-ruby');
await atom.workspace.open('file1.rb');
let symbols;
editor = getEditor();
editor.setCursorBufferPosition([26, 10]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(2);
expect(symbols[0].position).toEqual([1, 0]);
editor.setCursorBufferPosition([27, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(1);
expect(symbols[0].position).toEqual([0, 0]);
editor.setCursorBufferPosition([28, 5]);
symbols = await findDeclarationInProject(provider, editor);
expect(symbols.length).toBe(2);
expect(symbols[0].position).toEqual([31, 0]);
});
});
describe('project symbols', () => {
it('displays all tags', async () => {
await atom.workspace.open(directory.resolve('tagged.js'));
editor = getEditor();
let symbols = await getProjectSymbols(provider, editor);
expect(symbols.length).toBe(4);
expect(symbols[0].name).toBe('callMeMaybe');
expect(symbols[0].directory).toBe(directory.getPath());
expect(symbols[0].file).toBe('tagged.js');
expect(symbols[3].name).toBe('thisIsCrazy');
expect(symbols[3].directory).toBe(directory.getPath());
expect(symbols[3].file).toBe('tagged.js');
fs.removeSync(directory.resolve('tags'));
await wait(50);
symbols = await getProjectSymbols(provider, editor);
expect(symbols.length).toBe(0);
});
});
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,42 @@
module.exports = {
env: {
browser: true,
commonjs: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:node/recommended",
],
overrides: [],
parserOptions: {
ecmaVersion: "latest"
},
rules: {
"no-fallthrough": "off",
"no-case-declarations": "off",
"space-before-function-paren": ["error", {
anonymous: "always",
asyncArrow: "always",
named: "never"
}],
"node/no-unpublished-require": [
"error",
{
allowModules: ["electron"]
}
],
"node/no-missing-require": [
"error",
{
allowModules: ["atom"]
}
]
},
plugins: [
"jsdoc"
],
globals: {
atom: "writeable"
}
};

View File

@ -0,0 +1,20 @@
Copyright (c) 2023 Andrew Dupont
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,181 @@
# symbol-provider-tree-sitter package
Provides symbols to `symbols-view` via Tree-sitter queries.
Tree-sitter grammars [with tags queries](https://tree-sitter.github.io/tree-sitter/code-navigation-systems) can very easily give us a list of all the symbols in a file without the drawbacks of a `ctags`-based approach. For instance, they operate on the contents of the buffer, not the contents of the file on disk, so they work just fine in brand-new files and in files that have been modified since the last save.
This provider does not currently support project-wide symbol search, but possibly could do so in the future.
## Tags queries
This provider expects for a grammar to have specified a tags query in its grammar definition file. All the built-in Tree-sitter grammars will have such a file. If youre using a third-party Tree-sitter grammar that hasnt defined one, file an issue on Pulsar and well see what we can do.
If youre writing your own grammar, or contributing a `tags.scm` to a grammar without one, keep reading.
### Query syntax
The query syntax starts as a subset of what is described [on this page](https://tree-sitter.github.io/tree-sitter/code-navigation-systems). Heres what this package can understand:
* A query that consists of a `@definition.THING` capture with a `@name` capture inside will properly be understood as a symbol with a tag corresponding to `THING` and a name corresponding to the `@name` captures text.
* A query that consists of a `@reference.THING` capture with a `@name` capture inside will be ignored by default. If the proper setting is enabled, each of these references will become a symbol with a tag corresponding to `THING` and a name corresponding to the `@name` captures text.
* All other `@name` captures that are not within either a `@definition` or a `@reference` will be considered as a symbol in isolation. (These symbols can still specify a tag via a `#set!` predicate.)
To match the current behavior of the `symbols-view` package, you can usually take a `queries/tags.scm` file from a Tree-sitter repository — many parsers define them — and paste it straight into your grammars `tags.scm` file.
#### Advanced features
The text of the captured node is what will be displayed as the symbols name, but a few predicates are available to alter that field and others. Symbol predicates use `#set!` and the `symbol` namespace.
##### Node position descriptors
Several predicates take a **node position descriptor** as an argument. Its a string that resembles an object lookup chain in JavaScript:
```scm
(#set! symbol.prependTextForNode parent.parent.firstNamedChild)
```
Starting at the captured node, it describes a path to take within the tree in order to get to another meaningful node.
In all these examples, if the descriptor is invalid and does not return a node, the predicate will be ignored.
##### Changing the symbols name
There are several ways to add text to the beginning or end of the symbols name:
###### symbol.prepend
```scm
(class_declaration
name: (identifier) @name
(#set! symbol.prepend "Class: "))
```
The `symbol.prepend` predicate adds a constant string to the beginning of a symbol name. For a class `Foo` in JavaScript, this predicate would result in a symbol called `Class: Foo`.
###### symbol.append
```scm
(class_declaration
name: (identifier) @name
(#set! symbol.append " (class)"))
```
The `symbol.append` predicate adds a constant string to the end of a symbol name. For a class `Foo`, this predicate would result in a symbol called `Foo (class)`.
###### symbol.strip
```scm
(class_declaration
name: (identifier) @name
(#set! symbol.strip "^\\s+|\\s+$"))
```
The `symbol.strip` predicate will replace everything matched by the regular expression with an empty string. The pattern given is compiled into a JavaScript `RegExp` with an implied `g` (global) flag.
In this example, _if_ the `identifier` node included whitespace on either side of the symbol, the symbols name would be stripped of that whitespace before being shown in the UI.
###### symbol.prependTextForNode
```scm
(class_body (method_definition
name: (property_identifier) @name
(#set! symbol.prependTextForNode "parent.parent.previousNamedSibling")
(#set! symbol.joiner "#")
))
```
The `symbol.prependTextForNode` predicate will look up the text of the node referred to by the provided _node position descriptor_, then prepend that text to the symbol name. If `symbol.joiner` is provided, it will be inserted in between the two.
In this example, a `bar` method on a class named `Foo` would have a symbol name of `Foo#bar`.
###### symbol.prependSymbolForNode
```scm
(class_body (method_definition
name: (property_identifier) @name
(#set! symbol.prependSymbolForNode "parent.parent.previousNamedSibling")
(#set! symbol.joiner "#")
))
```
The `symbol.prependSymbolForNode` predicate will look up the symbol name of the node referred to by the provided _node position descriptor_, then prepend that name to the symbol name. If `symbol.joiner` is provided, it will be inserted in between the two.
Unlike `symbol.prependTextForNode`, the node referred to with the descriptor must have its own symbol name, and it must have been processed already — that is, it must be a symbol whose name was determined earlier than that of the current node.
This allows us to incorporate any transformations that were applied to the other nodes symbol name. We can use this to build “recursive” symbol names — for instance, JSON keys whose symbols consist of their entire key path from the root.
##### Adding the `context` field
The `context` field of a symbol is a short piece of text meant to give context. For instance, a symbol that represents a class method could have a `context` field that contains the name of the owning class. The `context` field is not filtered on.
###### symbol.contextNode
```scm
(class_body (method_definition
name: (property_identifier) @name
(#set! symbol.contextNode "parent.parent.previousNamedSibling")
))
```
The `symbol.contextNode` predicate will set the value of a symbols `context` property to the text of a node based on the provided _node position descriptor_.
###### symbol.context
```scm
(class_body (method_definition
name: (property_identifier) @name
(#set! symbol.context "class")
))
```
The `symbol.context` predicate will set the value of a symbols `context` property to a fixed string.
The point of `context` is to provide information to help you tell symbols apart, so you probably dont want to set it to a fixed value. But this predicate is available just in case.
##### Adding a tag
The `tag` field is a string (ideally a short string) that indicates a symbols kind or type. A `tag` for a class methods symbol might say `method`, whereas the symbol for the class itself might have a `tag` of `class`. These tags will be indicated in the UI with a badge or an icon.
The preferred method of adding a tag is to leverage the `@definition.` captures that are typically present in a tags file. For instance, in this excerpt from the JavaScript grammars `tags.scm` file…
```scm
(assignment_expression
left: [
(identifier) @name
(member_expression
property: (property_identifier) @name)
]
right: [(arrow_function) (function)]
) @definition.function
```
…the resulting symbol will infer a `tag` value of `function`.
In cases where this is impractical, you can provide the tag explicitly with a predicate.
###### symbol.icon
```scm
(class_body (method_definition
name: (property_identifier) @name
(#set! symbol.icon "package")
))
```
The icon to be shown alongside the symbol in a list. Will only be shown if the user has enabled the “Show Icons in Symbols View” option in the `symbols-view` settings. You can see the full list of available icons by invoking the **Styleguide: Show** command and browsing the “Icons” section. The value can include the preceding `icon-` or can omit it; e.g., `icon-package` and `package` are both valid values.
If this value is omitted, this provider will still attempt to match certain common tag values to icons. If `tag` is not present on the symbol, or is an uncommon value, there will be a blank space instead of an icon.
###### symbol.tag
```scm
(class_body (method_definition
name: (property_identifier) @name
(#set! symbol.tag "class")
))
```
The `symbol.tag` predicate will set the value of a symbols `tag` property to a fixed string.
The `tag` property is used to supply a word that represents the symbol in some way. For conventional symbols, this will often be something like `class` or `function`.

View File

@ -0,0 +1,391 @@
const { Point } = require('atom');
function resolveNodeDescriptor(node, descriptor) {
let parts = descriptor.split('.');
let result = node;
while (result !== null && parts.length > 0) {
let part = parts.shift();
if (!result[part]) { return null; }
result = result[part];
}
return result;
}
const PatternCache = {
getOrCompile(pattern) {
this.patternCache ??= new Map();
let regex = this.patternCache.get(pattern);
if (!regex) {
regex = new RegExp(pattern, 'g');
this.patternCache.set(pattern, regex);
}
return regex;
},
clear() {
this.patternCache?.clear();
},
};
function iconForTag(tag) {
switch (tag) {
case 'function':
return 'icon-gear';
case 'method':
return 'icon-gear';
case 'namespace':
return 'icon-tag';
case 'variable':
return 'icon-code';
case 'class':
return 'icon-package';
case 'constant':
return 'icon-primitive-square';
case 'property':
return 'icon-primitive-dot';
case 'interface':
return 'icon-key';
case 'constructor':
return 'icon-tools';
case 'module':
return 'icon-database';
default:
return null;
}
}
/**
* A container capture. When another capture's node is contained by the
* definition capture's node, it gets added to this instance.
*/
class Container {
constructor(capture, organizer) {
this.captureFields = new Map();
this.captureFields.set(capture.name, capture);
this.capture = capture;
this.node = capture.node;
this.organizer = organizer;
this.tag = capture.name.substring(capture.name.indexOf('.') + 1);
this.icon = iconForTag(this.tag);
this.position = capture.node.range.start;
}
getCapture(name) {
return this.captureFields.get(name);
}
hasCapture(capture) {
return this.captureFields.has(capture.name);
}
endsBefore(range) {
let containerRange = this.node.range;
return containerRange.end.compare(range.start) === -1;
}
add(capture) {
if (this.captureFields.has(capture.name)) {
console.warn(`Name already exists:`, capture.name);
}
// Any captures added to this definition need to be checked to make sure
// their nodes are actually descendants of this definition's node.
if (!this.node.range.containsRange(capture.node.range)) {
return false;
}
this.captureFields.set(capture.name, capture);
if (capture.name === 'name') {
this.nameCapture = new Name(capture, this.organizer);
}
return true;
}
isValid() {
return (
this.nameCapture &&
this.position instanceof Point
);
}
toSymbol() {
if (!this.nameCapture) return null;
let nameSymbol = this.nameCapture.toSymbol();
let symbol = {
name: nameSymbol.name,
shortName: nameSymbol.shortName,
tag: nameSymbol.tag ?? this.tag,
icon: nameSymbol.icon ?? iconForTag(nameSymbol.tag) ?? iconForTag(this.tag),
position: this.position
};
if (nameSymbol.context) {
symbol.context = nameSymbol.context;
}
return symbol;
}
}
class Definition extends Container {
constructor(...args) {
super(...args);
this.type = 'definition';
}
}
class Reference extends Container {
constructor(...args) {
super(...args);
this.type = 'reference';
}
}
class Name {
constructor(capture, organizer) {
this.type = 'name';
this.organizer = organizer;
this.props = capture.setProperties ?? {};
this.capture = capture;
this.node = capture.node;
this.position = capture.node.range.start;
this.name = this.resolveName(capture);
this.shortName = this.resolveName(capture, { short: true });
this.context = this.resolveContext(capture);
this.tag = this.resolveTag(capture);
this.icon = this.resolveIcon(capture);
}
getSymbolNameForNode(node) {
return this.organizer.nameCache.get(node.id);
}
resolveName(capture, { short = false } = {}) {
let { node, props } = this;
let base = node.text;
if (props['symbol.strip']) {
let pattern = PatternCache.getOrCompile(props['symbol.strip']);
base = base.replace(pattern, '');
}
// The “short name” is the symbol's base name before we prepend or append
// any text.
if (short) return base;
// TODO: Regex-based replacement?
if (props['symbol.prepend']) {
base = `${props['symbol.prepend']}${base}`;
}
if (props['symbol.append']) {
base = `${base}${props['symbol.append']}`;
}
let prefix = this.resolvePrefix(capture);
if (prefix) {
let joiner = props['symbol.joiner'] ?? '';
base = `${prefix}${joiner}${base}`;
}
this.organizer.nameCache.set(node.id, base);
return base;
}
resolveContext() {
let { node, props } = this;
let result = null;
if (props['symbol.contextNode']) {
let contextNode = resolveNodeDescriptor(node, props['symbol.contextNode']);
if (contextNode) {
result = contextNode.text;
}
}
if (props['symbol.context']) {
result = props['symbol.context'];
}
return result;
}
resolvePrefix() {
let { node, props } = this;
let symbolDescriptor = props['symbol.prependSymbolForNode'];
let textDescriptor = props['symbol.prependTextForNode'];
// Prepending with a symbol name requires that we already have determined
// the name for another node, which means the other node must have a
// corresponding symbol. But it allows for recursion.
if (symbolDescriptor) {
let other = resolveNodeDescriptor(node, symbolDescriptor);
if (other) {
let symbolName = this.getSymbolNameForNode(other);
if (symbolName) return symbolName;
}
}
// A simpler option is to prepend with a node's text. This works on any
// arbitrary node, even nodes that don't have their own symbol names.
if (textDescriptor) {
let other = resolveNodeDescriptor(node, textDescriptor);
if (other) {
return other.text;
}
}
return null;
}
resolveTag() {
return this.props['symbol.tag'] ?? null;
}
resolveIcon() {
let icon = this.props['symbol.icon'] ?? null;
if (icon && !icon.startsWith('icon-'))
icon = `icon-${icon}`;
return icon;
}
toSymbol() {
let { name, shortName, position, context, tag } = this;
let symbol = { name, shortName, position };
if (tag) {
symbol.tag = tag;
symbol.icon = iconForTag(tag);
}
if (context) symbol.context = context;
return symbol;
}
}
/**
* Keeps track of @definition.* captures and the captures they may contain.
*/
class CaptureOrganizer {
clear() {
this.nameCache ??= new Map();
this.nameCache.clear();
this.activeContainers = [];
this.definitions = [];
this.references = [];
this.names = [];
this.extraCaptures = [];
}
destroy() {
PatternCache.clear();
this.clear();
}
isDefinition(capture) {
return capture.name.startsWith('definition.');
}
isReference(capture) {
return capture.name.startsWith('reference.');
}
isName(capture) {
return capture.name === 'name';
}
finish(container) {
if (!container) return;
if (container instanceof Definition) {
this.definitions.push(container);
} else if (container instanceof Reference) {
this.references.push(container);
}
let index = this.activeContainers.indexOf(container);
if (index === -1) return;
this.activeContainers.splice(index, 1);
}
addToContainer(capture) {
let index = this.activeContainers.length - 1;
let added = false;
while (index >= 0) {
let container = this.activeContainers[index];
if (!container.hasCapture(capture)) {
if (container.add(capture)) {
added = true;
break;
}
}
index--;
}
return added;
}
pruneActiveContainers(capture) {
let { range } = capture.node;
let indices = [];
for (let [index, container] of this.activeContainers.entries()) {
if (container.endsBefore(range)) {
indices.unshift(index);
}
}
for (let index of indices) {
this.finish(this.activeContainers[index]);
}
}
process(captures, scopeResolver) {
scopeResolver.reset();
this.clear();
for (let capture of captures) {
if (!scopeResolver.store(capture)) continue;
this.pruneActiveContainers(capture);
if (this.isDefinition(capture)) {
this.activeContainers.push(new Definition(capture, this));
} else if (this.isReference(capture)) {
this.activeContainers.push(new Reference(capture, this));
} else if (this.isName(capture)) {
// See if this @name capture belongs with the most recent @definition
// capture.
if (this.addToContainer(capture)) {
continue;
}
this.names.push(new Name(capture, this));
} else {
if (!this.addToContainer(capture)) {
continue;
} else {
this.extraCaptures.push(capture);
}
}
}
while (this.activeContainers.length) {
this.finish(this.activeContainers[0]);
}
let symbols = [];
for (let definition of this.definitions) {
if (!definition.isValid()) continue;
symbols.push(definition.toSymbol());
}
if (atom.config.get('symbol-provider-tree-sitter.includeReferences')) {
for (let reference of this.references) {
if (!reference.isValid()) continue;
symbols.push(reference.toSymbol());
}
}
for (let name of this.names) {
symbols.push(name.toSymbol());
}
scopeResolver.reset();
return symbols;
}
}
module.exports = CaptureOrganizer;

View File

@ -0,0 +1,16 @@
const TreeSitterProvider = require('./tree-sitter-provider');
module.exports = {
activate () {
this.provider = new TreeSitterProvider();
},
deactivate () {
this.provider?.destroy?.();
},
provideSymbols () {
return this.provider;
}
};

View File

@ -0,0 +1,88 @@
const CaptureOrganizer = require('./capture-organizer');
const { Emitter } = require('atom');
class TreeSitterProvider {
constructor() {
this.packageName = 'symbol-provider-tree-sitter';
this.name = 'Tree-sitter';
this.isExclusive = true;
this.captureOrganizer = new CaptureOrganizer();
this.emitter = new Emitter();
this.disposable = atom.config.onDidChange('symbol-provider-tree-sitter', () => {
// Signal the consumer to clear its cache whenever we change the package
// config.
this.emitter.emit('should-clear-cache', { provider: this });
});
}
destroy() {
this.captureOrganizer.destroy();
this.disposable.dispose();
}
onShouldClearCache(callback) {
return this.emitter.on('should-clear-cache', callback);
}
canProvideSymbols(meta) {
let { editor, type } = meta;
// This provider can't crawl the whole project.
if (type === 'project' || type === 'project-find') return false;
// This provider works only for editors with Tree-sitter grammars.
let languageMode = editor?.getBuffer()?.getLanguageMode();
if (!languageMode?.atTransactionEnd) {
return false;
}
// This provider needs at least one layer to have a tags query.
let layers = languageMode.getAllLanguageLayers(l => !!l.tagsQuery);
if (layers.length === 0) {
return false;
}
// Return a value that will beat the built-in `ctags` provider, but which
// will (by default) lose to any provider from a community package.
return 0.999;
}
async getSymbols(meta) {
let { editor, signal } = meta;
let languageMode = editor?.getBuffer()?.getLanguageMode();
if (!languageMode) return null;
let scopeResolver = languageMode?.rootLanguageLayer?.scopeResolver;
if (!scopeResolver) return null;
let results = [];
// Wait for the buffer to be at rest so we know we're capturing against
// clean trees.
await languageMode.atTransactionEnd();
// The symbols-view package might've cancelled us in the interim.
if (signal.aborted) return null;
let layers = languageMode.getAllLanguageLayers(l => !!l.tagsQuery);
if (layers.length === 0) return null;
for (let layer of layers) {
let extent = layer.getExtent();
let captures = layer.tagsQuery.captures(
layer.tree.rootNode,
extent.start,
extent.end
);
results.push(
...this.captureOrganizer.process(captures, scopeResolver)
);
}
results.sort((a, b) => a.position.compare(b.position));
return results;
}
}
module.exports = TreeSitterProvider;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
{
"name": "symbol-provider-tree-sitter",
"main": "./lib/main",
"version": "1.0.0",
"description": "Provides symbols to symbols-view based on tree-sitter queries",
"repository": "https://github.com/pulsar-edit/pulsar",
"license": "MIT",
"engines": {
"atom": ">=1.0.0 <2.0.0",
"node": ">=14"
},
"providedServices": {
"symbol.provider": {
"description": "Allows external sources to suggest symbols for a given file or project.",
"versions": {
"1.0.0": "provideSymbols"
}
}
},
"configSchema": {
"includeReferences": {
"default": false,
"type": "boolean",
"description": "Whether to make symbols out of references (for example, function calls) in addition to definitions."
}
},
"devDependencies": {
"eslint": "^8.44.0",
"fs-plus": "^3.1.1",
"temp": "^0.9.4"
}
}

View File

@ -0,0 +1,13 @@
module.exports = {
env: { jasmine: true },
globals: {
waitsForPromise: true
},
rules: {
"node/no-unpublished-require": "off",
"node/no-extraneous-require": "off",
"no-unused-vars": "off",
"no-empty": "off",
"no-constant-condition": "off"
}
};

View File

@ -0,0 +1,6 @@
#define UNUSED(x) (void)(x)
static void f(int x)
{
UNUSED(x);
}

View File

@ -0,0 +1,3 @@
tagged.js
sample.js
other-file.js

View File

@ -0,0 +1,6 @@
// Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
// tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
// consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
// cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
// non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

View File

@ -0,0 +1,29 @@
// Another file for symbols to exist in. Used for project search.
var quicksort = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};
var quicksort2 = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,13 @@
var quicksort = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,11 @@
var thisIsCrazy = true;
function callMeMaybe() {
return "here's my number";
}
var iJustMetYou = callMeMaybe();
function duplicate() {
return true;
}

View File

@ -0,0 +1,10 @@
def foo
return <<-js
function bar () {
return 'aha!';
}
js
end

View File

@ -0,0 +1,33 @@
module A::Foo
B = 'b'
def bar!
end
def bar?
end
def baz
end
def baz=(*)
end
end
if bar?
baz
bar!
elsif !bar!
baz= 1
baz = 2
Foo = 3
{ :baz => 4 }
A::Foo::B
C::Foo::B
D::Foo::E
end
module D::Foo
end

View File

@ -0,0 +1,391 @@
const path = require('path');
const fs = require('fs-plus');
const temp = require('temp');
const TreeSitterProvider = require('../lib/tree-sitter-provider');
// Just for syntax highlighting.
function scm(strings) {
return strings.join('');
}
function getEditor() {
return atom.workspace.getActiveTextEditor();
}
async function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
let provider;
async function getSymbols(editor, type = 'file') {
let controller = new AbortController();
let symbols = await provider.getSymbols({
type,
editor,
signal: controller.signal
});
return symbols;
}
describe('TreeSitterProvider', () => {
let directory, editor;
beforeEach(async () => {
jasmine.unspy(global, 'setTimeout');
jasmine.unspy(Date, 'now');
atom.config.set('core.useTreeSitterParsers', true);
atom.config.set('core.useExperimentalModernTreeSitter', true);
await atom.packages.activatePackage('language-javascript');
atom.config.set('symbol-provider-tree-sitter.includeReferences', false);
provider = new TreeSitterProvider();
atom.project.setPaths([
temp.mkdirSync('other-dir-'),
temp.mkdirSync('atom-symbols-view-')
]);
directory = atom.project.getDirectories()[1];
fs.copySync(
path.join(__dirname, 'fixtures', 'js'),
atom.project.getPaths()[1]
);
fs.copySync(
path.join(__dirname, 'fixtures', 'ruby'),
atom.project.getPaths()[1]
);
});
describe('when a tree-sitter grammar is used for a file', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
let languageMode = editor.getBuffer().getLanguageMode();
await languageMode.ready;
});
it('is willing to provide symbols for the current file', () => {
let meta = { type: 'file', editor };
expect(provider.canProvideSymbols(meta)).toBe(0.999);
});
it('is not willing to provide symbols for an entire project', () => {
let meta = { type: 'project', editor };
expect(provider.canProvideSymbols(meta)).toBe(false);
});
it('provides all JavaScript functions', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('quicksort');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('sort');
expect(symbols[1].position.row).toEqual(1);
});
});
describe('when a non-tree-sitter grammar is used for a file', () => {
beforeEach(async () => {
atom.config.set('core.useTreeSitterParsers', false);
atom.config.set('core.useExperimentalModernTreeSitter', false);
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
});
it('is not willing to provide symbols for the current file', () => {
expect(editor.getGrammar().rootLanguageLayer).toBe(undefined);
let meta = { type: 'file', editor };
expect(provider.canProvideSymbols(meta)).toBe(false);
});
});
// TODO: Test that `canProvideSymbols` returns `false` when no layer has a
// tags query.
describe('when the buffer is new and unsaved', () => {
let grammar;
beforeEach(async () => {
await atom.workspace.open();
editor = getEditor();
grammar = atom.grammars.grammarForId('source.js');
editor.setGrammar(grammar);
await editor.getBuffer().getLanguageMode().ready;
});
it('is willing to provide symbols', () => {
let meta = { type: 'file', editor };
expect(provider.canProvideSymbols(meta)).toBe(0.999);
});
describe('and has content', () => {
beforeEach(async () => {
let text = fs.readFileSync(
path.join(__dirname, 'fixtures', 'js', 'sample.js')
);
editor.setText(text);
await editor.getBuffer().getLanguageMode().atTransactionEnd();
});
it('provides symbols just as if the file were saved on disk', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('quicksort');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('sort');
expect(symbols[1].position.row).toEqual(1);
});
});
});
describe('when the file has multiple language layers', () => {
beforeEach(async () => {
await atom.packages.activatePackage('language-ruby');
await atom.workspace.open(directory.resolve('embed.rb'));
editor = getEditor();
await editor.getBuffer().getLanguageMode().ready;
});
it('detects symbols across several layers', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('foo');
expect(symbols[0].position.row).toEqual(1);
expect(symbols[1].name).toBe('bar');
expect(symbols[1].position.row).toEqual(4);
});
});
describe('when the tags query contains @definition captures', () => {
let grammar;
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
let languageMode = editor.getBuffer().getLanguageMode();
await languageMode.ready;
grammar = editor.getGrammar();
await grammar.setQueryForTest(
'tagsQuery',
scm`
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
) @definition.function
`
);
});
it('can infer tag names from those captures', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('quicksort');
expect(symbols[0].tag).toBe('function');
expect(symbols[1].name).toBe('sort');
expect(symbols[1].tag).toBe('function');
});
});
describe('when the tags query contains @reference captures', () => {
let grammar;
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
let languageMode = editor.getBuffer().getLanguageMode();
await languageMode.ready;
grammar = editor.getGrammar();
await grammar.setQueryForTest(
'tagsQuery',
scm`
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
) @definition.function
(
(call_expression
function: (identifier) @name) @reference.call
(#not-match? @name "^(require)$"))
`
);
});
it('skips references when they are disabled in settings', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols.length).toBe(2);
});
it('includes references when they are enabled in settings', async () => {
atom.config.set('symbol-provider-tree-sitter.includeReferences', true);
let symbols = await getSymbols(editor, 'file');
expect(symbols.length).toBe(5);
expect(symbols.map(s => s.tag)).toEqual(
['function', 'function', 'call', 'call', 'call']
);
});
});
describe('when the tags query uses the predicate', () => {
let grammar;
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = getEditor();
let languageMode = editor.getBuffer().getLanguageMode();
await languageMode.ready;
grammar = editor.getGrammar();
});
describe('symbol.strip', () => {
beforeEach(async () => {
await grammar.setQueryForTest('tagsQuery', scm`
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
(#set! symbol.strip "ort$")
)
`);
});
it('strips the given text from each symbol', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('quicks');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('s');
expect(symbols[1].position.row).toEqual(1);
});
});
describe('symbol.prepend', () => {
beforeEach(async () => {
await grammar.setQueryForTest('tagsQuery', scm`
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
(#set! symbol.prepend "Foo: ")
)
`);
});
it('prepends the given text to each symbol', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('Foo: quicksort');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('Foo: sort');
expect(symbols[1].position.row).toEqual(1);
});
});
describe('symbol.append', () => {
beforeEach(async () => {
await grammar.setQueryForTest('tagsQuery', scm`
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
(#set! symbol.append " (foo)")
)
`);
});
it('appends the given text to each symbol', async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('quicksort (foo)');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('sort (foo)');
expect(symbols[1].position.row).toEqual(1);
});
});
describe('symbol.prependTextForNode', () => {
beforeEach(async () => {
await grammar.setQueryForTest('tagsQuery', scm`
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
(#set! test.onlyIfDescendantOfType function)
(#set! symbol.prependTextForNode "parent.parent.parent.parent.parent.firstNamedChild")
(#set! symbol.joiner ".")
(#set! test.final true)
)
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
)
`);
});
it(`prepends the associated node's text to each symbol`, async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('quicksort');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('quicksort.sort');
expect(symbols[1].position.row).toEqual(1);
});
});
describe('symbol.prependSymbolForNode', () => {
beforeEach(async () => {
await grammar.setQueryForTest('tagsQuery', scm`
; Outer function has prepended text...
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
(#set! test.onlyIfNotDescendantOfType function)
(#set! symbol.prepend "ROOT: ")
(#set! test.final true)
)
; which the inner function picks up on.
(
(variable_declaration
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function)]))
(#set! test.onlyIfDescendantOfType function)
(#set! symbol.prependSymbolForNode "parent.parent.parent.parent.parent.firstNamedChild")
(#set! symbol.joiner ".")
(#set! test.final true)
)
`);
});
it(`prepends the associated node's symbol name to each symbol`, async () => {
let symbols = await getSymbols(editor, 'file');
expect(symbols[0].name).toBe('ROOT: quicksort');
expect(symbols[0].position.row).toEqual(0);
expect(symbols[1].name).toBe('ROOT: quicksort.sort');
expect(symbols[1].position.row).toEqual(1);
});
});
});
});

View File

@ -0,0 +1,42 @@
module.exports = {
env: {
browser: true,
commonjs: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:node/recommended",
],
overrides: [],
parserOptions: {
ecmaVersion: "latest"
},
rules: {
"no-fallthrough": "off",
"no-case-declarations": "off",
"space-before-function-paren": ["error", {
anonymous: "always",
asyncArrow: "always",
named: "never"
}],
"node/no-unpublished-require": [
"error",
{
allowModules: ["electron"]
}
],
"node/no-missing-require": [
"error",
{
allowModules: ["atom"]
}
]
},
plugins: [
"jsdoc"
],
globals: {
atom: "writeable"
}
};

View File

@ -0,0 +1,27 @@
# symbols-view
Display a list of symbols in the editor. Typically, a symbol will correspond to a meaningful part of a source code file (like a function definition) but can refer to other important parts of files depending on context.
## Providers
`symbols-view` uses a provider/subscriber model similar to that of `autocomplete-plus`. This package implements the UI, but it relies on other packages to suggest symbols.
### Built-in providers
The original symbol provider, `ctags`, now lives in its own provider package called `symbol-provider-ctags`. Another package, `symbol-provider-tree-sitter`, is the preferred provider (by default) in buffers that use a Tree-sitter grammar.
### Community package providers
Any package can act as a symbol provider. [These are the packages on the Pulsar Package Repository that provide the `symbol.provider` service.](https://web.pulsar-edit.dev/packages?service=symbol.provider&serviceType=provided)
## Commands
|Command|Description|Keybinding (Linux/Windows)|Keybinding (macOS)|
|-------|-----------|------------------|-----------------|
|`symbols-view:toggle-file-symbols`|Show all symbols in current file|<kbd>ctrl-r</kbd>|<kbd>cmd-r</kbd>|
|`symbols-view:toggle-project-symbols`|Show all symbols in the project|<kbd>ctrl-shift-r</kbd>|<kbd>cmd-shift-r</kbd>|
|`symbols-view:go-to-declaration`|Jump to the symbol under the cursor|<kbd>ctrl-alt-down</kbd>|<kbd>cmd-alt-down</kbd>|
|`symbols-view:return-from-declaration`|Return from the jump|<kbd>ctrl-alt-up</kbd>|<kbd>cmd-alt-up</kbd>|
|`symbols-view:show-active-providers`|Display a list of all known symbol providers|||
Commands relating to project-wide symbols may fail if no provider can satisfy a request for project-wide symbols. See `symbol-provider-ctags` for more information.

View File

@ -0,0 +1,20 @@
'.platform-darwin atom-text-editor:not([mini])':
'cmd-r': 'symbols-view:toggle-file-symbols'
'cmd-alt-down': 'symbols-view:go-to-declaration'
'cmd-alt-up': 'symbols-view:return-from-declaration'
'.platform-win32 atom-text-editor:not([mini])':
'ctrl-r': 'symbols-view:toggle-file-symbols'
'ctrl-alt-down': 'symbols-view:go-to-declaration'
'ctrl-alt-up': 'symbols-view:return-from-declaration'
'.platform-linux atom-text-editor:not([mini])':
'ctrl-r': 'symbols-view:toggle-file-symbols'
'ctrl-alt-down': 'symbols-view:go-to-declaration'
'ctrl-alt-up': 'symbols-view:return-from-declaration'
'.platform-darwin':
'cmd-shift-r': 'symbols-view:toggle-project-symbols'
'.platform-win32, .platform-linux':
'ctrl-shift-r': 'symbols-view:toggle-project-symbols'

View File

@ -0,0 +1,39 @@
const { CompositeDisposable, Emitter } = require('atom');
const Config = {
activate() {
if (this.activated) return;
this.emitter ??= new Emitter();
this.subscriptions = new CompositeDisposable();
this.subscriptions.add(
atom.config.onDidChange('symbols-view', config => {
this.emitter.emit('did-change-config', config);
})
);
this.activated = true;
},
deactivate() {
this.activated = false;
this.subscriptions?.dispose();
},
get(key) {
return atom.config.get(`symbols-view.${key}`);
},
set(key, value) {
return atom.config.set(`symbols-view.${key}`, value);
},
observe(key, callback) {
return atom.config.observe(`symbols-view.${key}`, callback);
},
onDidChange(callback) {
return this.emitter.on('did-change-config', callback);
}
};
module.exports = Config;

View File

@ -0,0 +1,52 @@
function parseTagName (selector) {
if (!selector.includes('.')) {
return [selector, null];
}
let tagName = selector.substring(0, selector.indexOf('.'));
let classes = selector.substring(selector.indexOf('.') + 1);
let classList = classes.split('.');
return [tagName ?? 'div', classList];
}
function el (selector, ...args) {
let attributes = null;
if (typeof args[0] === 'object' && !args[0].nodeType) {
attributes = args.shift();
}
// Extract a tag name and any number of class names from the first argument.
let [tagName, classList] = parseTagName(selector);
let element = document.createElement(tagName);
if (attributes) {
// If an object is given as a second argument, it's a list of
// attribute/value pairs.
for (let [attr, value] of Object.entries(attributes)) {
element.setAttribute(attr, value);
}
}
// Any further arguments are children of the element.
for (let item of args) {
if (!item) continue;
if (typeof item === 'string') {
item = document.createTextNode(item);
} else if (Array.isArray(item)) {
// This is an array; append its children, but do not append it.
for (let n of item) {
element.appendChild(n);
}
continue;
}
element.appendChild(item);
}
if (classList) {
element.classList.add(...classList);
}
return element;
}
module.exports = el;

View File

@ -0,0 +1,334 @@
const { CompositeDisposable, Point } = require('atom');
const { match } = require('fuzzaldrin');
const Config = require('./config');
const SymbolsView = require('./symbols-view');
const el = require('./element-builder');
const { badge, isIterable, timeout } = require('./util');
class FileView extends SymbolsView {
constructor (stack, broker) {
super(stack, broker);
this.cachedResults = new Map();
// Cached results can be partially invalidated. If a provider wants to
// clear only its own cached results, keep track of it so that we know to
// ask it for new symbols in spite of the presence of other results in the
// cache.
this.providersWithInvalidatedCaches = new Map();
this.watchedEditors = new WeakSet();
this.editorsSubscription = atom.workspace.observeTextEditors(editor => {
if (this.watchedEditors.has(editor)) return;
const removeFromCache = (provider = null) => {
if (!provider) {
this.cachedResults.delete(editor);
this.providersWithInvalidatedCaches.delete(editor);
return;
}
let results = this.cachedResults.get(editor);
if (!results || results.length === 0) return;
results = results.filter(sym => {
return sym.providerId !== provider.packageName;
});
if (results.length === 0) {
// No other providers had cached any symbols, so we can do the simple
// thing here.
this.cachedResults.delete(editor);
this.providersWithInvalidatedCaches.delete(editor);
return;
}
// There's at least one remaining cached symbol. When we fetch this
// cache result, we need a way of knowing whether this cache entry is
// comprehensive. So we'll add this provider to a list of providers
// that will need re-querying.
this.cachedResults.set(editor, results);
let providers = this.providersWithInvalidatedCaches.get(editor);
if (!providers) {
providers = new Set();
this.providersWithInvalidatedCaches.set(editor, providers);
}
providers.add(provider);
};
const removeAllFromCache = () => removeFromCache(null);
const editorSubscriptions = new CompositeDisposable();
let buffer = editor.getBuffer();
// All the core actions that can invalidate the symbol cache.
editorSubscriptions.add(
// Some of them invalidate the entire cache…
editor.onDidChangeGrammar(removeAllFromCache),
editor.onDidSave(removeAllFromCache),
editor.onDidChangePath(removeAllFromCache),
buffer.onDidReload(removeAllFromCache),
buffer.onDidDestroy(removeAllFromCache),
buffer.onDidStopChanging(removeAllFromCache),
Config.onDidChange(removeAllFromCache),
// …and others invalidate only the cache for one specific provider.
this.broker.onDidAddProvider(removeFromCache),
this.broker.onDidRemoveProvider(removeFromCache),
this.broker.onShouldClearCache((bundle = {}) => {
let { provider = null, editor: someEditor = null } = bundle;
if (someEditor && editor.id !== someEditor.id) return;
removeFromCache(provider);
})
);
editorSubscriptions.add(
editor.onDidDestroy(() => {
this.watchedEditors.delete(editor);
editorSubscriptions.dispose();
})
);
this.watchedEditors.add(editor);
});
}
destroy () {
this.editorsSubscription.dispose();
return super.destroy();
}
elementForItem ({ position, name, tag, icon, context, providerName }) {
// Style matched characters in search results.
const matches = match(name, this.selectListView.getFilterQuery());
let badges = [];
if (providerName && this.shouldShowProviderName) {
badges.push(providerName);
}
if (tag) {
badges.push(tag);
}
let primaryLineClasses = ['primary-line'];
if (this.showIconsInSymbolsView) {
if (icon) {
primaryLineClasses.push('icon', icon);
} else {
primaryLineClasses.push('no-icon');
}
}
// The “primary” results line shows the symbol's name and its tag, if any.
let primary = el(`div.${primaryLineClasses.join('.')}`,
el('div.name',
SymbolsView.highlightMatches(this, name, matches)
),
badges && el('div.badge-container',
...badges.map(b => badge(b, { variant: this.useBadgeColors }))
)
);
// The “secondary” results line shows the symbols row number and its
// context, if any.
let secondaryLineClasses = ['secondary-line'];
if (this.showIconsInSymbolsView) {
secondaryLineClasses.push('no-icon');
}
let secondary = el(`div.${secondaryLineClasses.join('.')}`,
el('span.location', `Line ${position.row + 1}`),
context && el('span.context', context)
);
return el('li.two-lines', primary, secondary);
}
didChangeSelection (item) {
let quickJump = Config.get('quickJumpToFileSymbol');
if (quickJump && item) this.openTag(item);
}
async didCancelSelection () {
this.abortController?.abort();
await this.cancel();
let editor = this.getEditor();
if (editor && this.initialState) {
this.deserializeEditorState(editor, this.initialState);
}
this.initialState = null;
}
didConfirmEmptySelection () {
this.abortController?.abort();
super.didConfirmEmptySelection();
}
async toggle () {
if (this.panel.isVisible()) await this.cancel();
let editor = this.getEditor();
// Remember exactly where the editor is so that we can restore that state
// if the user cancels.
let quickJump = Config.get('quickJumpToFileSymbol');
if (quickJump && editor) {
this.initialState = this.serializeEditorState(editor);
}
let populated = this.populate(editor);
if (!populated) return;
this.attach();
}
serializeEditorState (editor) {
let editorElement = atom.views.getView(editor);
let scrollTop = editorElement.getScrollTop();
return {
bufferRanges: editor.getSelectedBufferRanges(),
scrollTop
};
}
deserializeEditorState (editor, { bufferRanges, scrollTop }) {
let editorElement = atom.views.getView(editor);
editor.setSelectedBufferRanges(bufferRanges);
editorElement.setScrollTop(scrollTop);
}
getEditor () {
return atom.workspace.getActiveTextEditor();
}
getPath () {
return this.getEditor()?.getPath();
}
getScopeName () {
return this.getEditor()?.getGrammar()?.scopeName;
}
isValidSymbol (symbol) {
if (!symbol.position || !(symbol.position instanceof Point)) return false;
if (typeof symbol.name !== 'string') return false;
return true;
}
async populate (editor) {
let result = this.cachedResults.get(editor);
let providersToQuery = this.providersWithInvalidatedCaches.get(editor);
if (result && !providersToQuery?.size) {
let symbols = result;
await this.updateView({
items: symbols
});
return true;
} else {
await this.updateView({
items: [],
loadingMessage: 'Generating symbols\u2026'
});
result = this.generateSymbols(editor, result, providersToQuery);
if (result?.then) result = await result;
this.providersWithInvalidatedCaches.delete(editor);
if (result == null) {
this.cancel();
return false;
}
result.sort((a, b) => a.position.compare(b.position));
await this.updateView({
items: result,
loadingMessage: null
});
return true;
}
}
async generateSymbols (editor, existingSymbols = null, onlyProviders = null) {
this.abortController?.abort();
this.abortController = new AbortController();
let meta = { type: 'file', editor, timeout: this.timeoutMs };
// The signal is how a provider can stop doing work if it's going async,
// since it'll be able to tell if we've cancelled this command and no
// longer need the symbols we asked for.
let signal = this.abortController.signal;
let providers = await this.broker.select(meta);
// If our last cache result was only partially invalidated, `onlyProviders`
// will be a `Set` of providers that need re-querying — but only if the
// broker selected them again in the first place.
//
// When re-using a cache result that was preserved in its entirety, we
// don't give the broker a chance to assemble another list of providers. We
// should act similarly in the event of partial invalidation, and ignore
// any providers _except_ the ones whose caches were invalidated.
if (onlyProviders) {
providers = providers.filter(p => onlyProviders.has(p));
}
if (providers?.length === 0) {
// TODO: Either show the user a notification or just log a warning to the
// console, depending on the user's settings and whether we've notified
// about this already during this session.
return existingSymbols;
}
let done = (symbols, provider) => {
if (signal.aborted) return;
// If these don't match up, our results are stale.
if (signal !== this.abortController.signal) return;
if (!isIterable(symbols)) {
error(`Provider did not return a list of symbols`, provider);
return;
}
this.addSymbols(allSymbols, symbols, provider);
};
let error = (err, provider) => {
if (signal.aborted) return;
let message = typeof err === 'string' ? err : err.message;
console.error(`Error in retrieving symbols from provider ${provider.name}: ${message}`);
};
let tasks = [];
let allSymbols = existingSymbols ? [...existingSymbols] : [];
for (let provider of providers) {
try {
// Each provider can return a list of symbols directly or a promise.
let symbols = this.getSymbolsFromProvider(provider, signal, meta);
if (symbols?.then) {
// This is a promise, so we'll add it to the list of tasks that we
// need to wait for.
let task = symbols
.then((result) => done(result, provider))
.catch(err => error(err, provider));
tasks.push(task);
} else if (isIterable(symbols)) {
// This is a valid list of symbols, so the provider acted
// synchronously. Add it to the results list.
done(symbols, provider);
} else {
error(`Provider did not return a list of symbols`, provider);
}
} catch (err) {
error(err, provider);
}
}
if (tasks.length > 0) {
await Promise.race([Promise.allSettled(tasks), timeout(this.timeoutMs)]);
}
await this.updateView({ loadingMessage: null });
if (signal.aborted) {
// This means the user cancelled the task. No cleanup necessary; the
// `didCancelSelection` handler would've taken care of that.
return null;
}
if (allSymbols.length > 0) {
// Only cache non-empty results.
this.cachedResults.set(editor, allSymbols);
}
return allSymbols;
}
}
module.exports = FileView;

View File

@ -0,0 +1,29 @@
const SymbolsView = require('./symbols-view');
// TODO: Does this really need to extend SymbolsView?
module.exports = class GoBackView extends SymbolsView {
toggle () {
let previous = this.stack.pop();
if (!previous) return;
let restorePosition = () => {
if (!previous.position) return;
this.moveToPosition(previous.position, { beginningOfLine: false });
};
let allEditors = atom.workspace.getTextEditors();
let previousEditor = allEditors.find(e => e.id === previous.editorId);
if (previousEditor) {
let pane = atom.workspace.paneForItem(previousEditor);
pane.setActiveItem(previousEditor);
restorePosition();
} else if (previous.file) {
// The editor is not there anymore; e.g., a package like `zentabs` might
// have automatically closed it when a new editor view was opened. So we
// should restore it if we can.
atom.workspace.open(previous).then(restorePosition);
}
}
}

View File

@ -0,0 +1,114 @@
const SymbolsView = require('./symbols-view');
const { timeout } = require('./util');
module.exports = class GoToView extends SymbolsView {
constructor (stack, broker) {
super(stack, broker);
}
toggle () {
if (this.panel.isVisible()) {
this.cancel();
} else {
this.populate();
}
}
detached () {
// TODO
this.abortController?.abort();
}
async populate () {
let editor = atom.workspace.getActiveTextEditor();
if (!editor) return;
let symbols = await this.generateSymbols(editor);
if (symbols?.length === 0) {
// TODO
console.warn('No symbols!');
return;
}
if (symbols.length === 1) {
if (this.openTag(symbols[0], { pending: true })) return;
}
// There must be multiple tags.
await this.updateView({ items: symbols });
this.attach();
}
shouldBePending () {
return true;
}
async generateSymbols (editor, range = null) {
this.abortController?.abort();
this.abortController = new AbortController();
let meta = {
type: 'project-find',
editor,
paths: atom.project.getPaths()
};
if (range) {
meta.range = range;
meta.query = editor.getTextInBufferRange(range);
}
let signal = this.abortController.signal;
let providers = await this.broker.select(meta);
if (providers?.length === 0) {
// TODO
return [];
}
let allSymbols = [];
let done = (symbols, provider) => {
if (signal.aborted) return;
if (!Array.isArray(symbols)) {
error(`Provider did not return a list of symbols`, provider);
return;
}
this.addSymbols(allSymbols, symbols, provider);
};
let error = (err, provider) => {
if (signal.aborted) return;
let message = typeof err === 'string' ? err : err.message;
console.error(`Error in retrieving symbols from provider ${provider.name}: ${message}`);
};
let tasks = [];
for (let provider of providers) {
try {
let symbols = this.getSymbolsFromProvider(provider, signal, meta);
if (symbols?.then) {
let task = symbols
.then(result => done(result, provider))
.catch(err => error(err, provider));
tasks.push(task);
} else {
done(symbols, provider);
}
} catch (err) {
error(err, provider);
}
}
if (tasks.length > 0) {
await Promise.race([Promise.allSettled(tasks), timeout(this.timeoutMs)]);
}
if (signal.aborted) {
return null;
}
return allSymbols;
}
};

298
packages/symbols-view/lib/main.d.ts vendored Normal file
View File

@ -0,0 +1,298 @@
import type { TextEditor, Point, Range as AtomRange } from 'atom';
type MaybePromise<T> = T | Promise<T>;
// The properties that a provider is allowed to modify on a `SelectList`
// instance during symbol retrieval.
//
// A provider that marks itself as `isExclusive: true` will be allowed to set
// certain UI messages on the `SelectList`. This allows the provider to offer
// UI guidance messages.
//
// For instance, if a project-wide symbol search is taking place, the provider
// could set an `emptyMessage` of “Query must be at least X characters long” to
// explain why no results are present at first.
type ListControllerParams = Partial<{
errorMessage: string,
emptyMessage: string,
loadingMessage: string,
laodingBadge: string
}>;
type ListControllerParamName = keyof ListControllerParams;
// An object given to the exclusive provider during symbol retrieval. The
// provider can use this to control certain aspects of the symbol list's UI.
type ListController = {
// Set props on the `SelectList` instance.
set (params: ListControllerParams): void,
// Clear any number of props on the `SelectList` instance.
clear(...propNames: ListControllerParamName[]): void
};
export type SymbolPosition = {
// An instance of `Point` describing the symbol's location. The `column`
// value of the point may be ignored, depending on the user's settings. At
// least one of `position` and `range` must exist.
position: Point;
};
export type SymbolRange = {
// An exact range describing the bounds of a given token. If present, might
// be used to highlight the token when selected by the user, though that
// depends on the user's settings. At least one of `position` and `range`
// must exist.
range: AtomRange
};
export type SymbolDirectoryAndFile = {
// The name of the file that contains the symbol. Will be shown in the UI.
file: string,
// The path of to the directory of the file that contains the symbol. Should
// not contain the file name.
directory: string
};
export type SymbolPath = {
// The full path to the file that contains the symbol.
path: string
};
// The only required fields in a file symbol are (a) the `name` of the symbol,
// and (b) a reference to its row in the buffer (via either `position` or
// `range`). Other fields are optional, but make for a richer UI presentation.
export type FileSymbol = (SymbolPosition | SymbolRange) & {
// The name of the symbol. This value will be shown in the UI and will be
// filtered against if the user types in the text box. Required.
name: string,
// A word representing the symbol in some way. Typically this would describe
// the symbol — function, constant, et cetera — but can be used however the
// provider sees fit. If present, will be included in the symbol list as a
// badge.
tag?: string
// A _short_ string of explanatory text. Optional. Can be used for text that
// is contexually significant to the symbol; for instance, a method or field
// might describe the class that owns it. Symbol consumers will expect this
// field to be short, and will not devote much space to it in the interface,
// so this field _should not_ contain unbounded text.
context?: string
// POSSIBLE ENHANCEMENTS (UNIMPLEMENTED!):
//
// I don't necessarily find these useful myself, or at least not useful
// enough to warrant their inclusion in a space-constrained list of symbols,
// but some people might want these to be present.
// A description of the symbol in code or pseudocode. For functions, this
// could be a function signature, along with parameter names and (if known)
// types.
//
// This field would receive its own line in a symbol list.
signature?: string
// The literal line of code containing the symbol. A symbol consumer could
// try to retrieve this information itself, but some symbol providers would
// be able to supply it much more simply.
//
// This field would receive its own line in a symbol list.
source?: string
};
// A project symbol has the additional requirement of specifying the file in
// which each symbol is located, via either the `path` property or both
// `directory` and `file`.
export type ProjectSymbol = FileSymbol & (SymbolDirectoryAndFile | SymbolPath);
// Metadata received by a symbol provider as part of a call to
// `canProvideSymbols` or `getSymbols`.
export type SymbolMeta = {
// The type of action being performed:
//
// * `file`: A symbol search within the current file.
// * `project`: A project-wide symbol search.
// * `project-find`: A project-wide attempt to resolve a reference based on
// (a) the position of the cursor, (b) the value of the editor's current
// text selection, or (c) whatever word was clicked on in the IDE.
type: 'file' | 'project' | 'project-find',
// The current text editor.
editor: TextEditor,
// The relevant search term, if any.
//
// When `type` is `project`, this will represent the text that the user has
// typed into a search field in order to filter the list of symbols.
//
// When `type` is `project-find`, this will represent the text that the IDE
// wants to resolve.
//
// When `type` is `file`, this field will be absent, because file symbols are
// queried only initially, before the user has typed anything; all winnowing
// is done on the frontend as the user types.
query?: string,
// The relevant range in the buffer.
//
// This may be present when `type` is `project-find` and the consumer wants
// to resolve an arbitrary buffer range instead of the word under the cursor.
range?: Range,
// An `AbortSignal` that represents whether the task has been cancelled. This
// will happen if the user cancels out of the symbol UI while waiting for
// symbols, or if they type a new character in the query field before the
// results have returned for the previous typed character. It will also
// happen if the provider exceeds the amount of time allotted to it (see
// `timeoutMs` below).
//
// If the provider goes async at any point, it should check the signal after
// resuming. If the signal has aborted, then there is no point in continuing.
// The provider should immediately return/resolve with `null` and avoid doing
// unnecessary further work.
signal: AbortSignal,
// The amount of time, in milliseconds, the provider has before it must
// return results. This value is configurable by the user. If the provider
// doesn't return anything after this amount of time, it will be ignored.
//
// The provider is not in charge of ensuring that it returns results within
// this amount of time; `symbols-view` enforces that on its own. This value
// is given to providers so that they can act wisely when faced with a choice
// between “search for more symbols” and “return what we have.”
//
// The `timeoutMs` property is only present when the appropriate symbol list
// UI is not yet present. Its purpose is to show the UI within a reasonable
// amount of time. If the UI is already present — for instance, when
// winnowing results in a project-wide symbol search — `timeoutMs` will be
// omitted, and the provider can take as much time as it deems appropriate.
timeoutMs?: number
};
type FileSymbolMeta = SymbolMeta & { type: 'file' };
type ProjectSymbolMeta = SymbolMeta & { type: 'project' | 'project-find' };
// Symbol metadata that will be passed to the `canProvideSymbols` method.
export type PreliminarySymbolMeta = Omit<SymbolMeta, 'signal'>;
export interface SymbolProvider {
// A human-readable name for your provider. This name may be displayed to the
// user, and it's how they can configure `symbols-view` to prefer
// certain providers over others.
name: string,
// The name of your package. This is present so that the user can find out
// where a given provider comes from. In the settings for preferring certain
// providers over others, a package name can be specified instead of a
// specific provider name — either because that's the value the user
// remembers, or because the package contains several providers and the user
// wishes to express a preference for all those providers at once.
packageName: string,
// If present, will be called on window teardown, or if `symbols-view`
// or the provider's own package is disabled.
destroy?(): void,
// An optional method. If it exists, the main package will use it to register
// a callback so that it can clear the cache of this provider's symbols.
//
// The main package will automatically clear its cache for these reasons:
//
// * when the main package's config changes (entire cache);
// * when any provider is activated or deactivated (single provider's cache);
// * when the buffer is modified in any of several ways, including grammar
// change, save, or buffer change (entire cache).
//
// If your provider may have its cache invalidated for reasons not in this
// list, you should implement `onShouldClearCache` and invoke any callback
// that registers for it. The `EventEmitter` pattern found throughout Pulsar
// is probably how you want to pull this off.
onShouldClearCache?(callback: () => TextEditor): void,
// Whether this provider aims to be the main symbol provider for a given
// file. The “exclusive” provider competes with the other workhorse providers
// of symbols like `ctags` and Tree-sitter to offer typical symbols like
// classes, method names, and the like. A maximum of one exclusive provider
// will be chosen for any task, depending on which one scores highest.
//
// “Supplemental” providers are those that contribute more specialized kinds
// of symbols. These providers generally do not compete with exclusive
// providers, or with each other, and can add symbols to any exclusive
// providers results.
isExclusive?: boolean,
// Indicates whether the provider can provide symbols for a given task. Can
// return either a boolean or a number; boolean `true` is equivalent to a
// score of `1`, and boolean `false` is equivalent to a score of `0`.
//
// This method receives the same metadata bundle that will be present in the
// subsequent call to `getSymbols`. The provider can inspect this metadata
// and decide whether it can fulfill the given symbols request.
//
// Examples:
//
// * A provider that can analyze the current file, but not the entire
// project, should return `false` for any requests where `type` does not
// equal `file`.
// * A provider that works by analyzing code on disk, rather than looking at
// the current unsaved contents of buffers, could return a slightly lower
// score if asked to complete symbols for a file that has been modified.
// This would indicate that itd be a slightly worse than usual candidate.
//
// Since language server providers will have to ask their servers about
// capabilities, this method can go async, though its strongly suggested to
// keep it synchronous if possible.
//
// To avoid a number war, any numeric value greater than `1` returned from
// `canProvideSymbols` will be clamped to `1`. The user can break ties by
// choosing their preferred providers in the package settings.
canProvideSymbols(meta: PreliminarySymbolMeta): MaybePromise<boolean | number>,
// Returns a list of symbols.
//
// If there are no results, you should return an empty array. If the request
// is invalid or cannot be completed — for instance, if the user cancels the
// task — you should return `null`.
//
// The second argument, `listController`, will be present _only_ for the
// provider marked with `isExclusive: true`. It allows the provider to set
// and clear UI messages if needed. Supplemental providers don't receive this
// argument.
//
// This method can go async if needed. Whenever it performs an async task, it
// should check `meta.signal` afterward to see if it should cancel.
getSymbols(meta: FileSymbolMeta, listController?: ListController): MaybePromise<FileSymbol[] | null>
getSymbols(meta: ProjectSymbolMeta, listController?: ListController): MaybePromise<ProjectSymbol[] | null>
}
export type SymbolProviderMainModule = {
activate(): void,
deacivate(): void,
// No business logic should go in here. If a package wants to provide symbols
// only under certain circumstances, it should decide those circumstances on
// demand, rather than return this provider only conditionally.
//
// A provider author may argue that they should be allowed to inspect the
// environment before deciding what (or if) to return — but anything they'd
// inspect is something that can change mid-session. Too complicated. All
// provider decisions can get decided at runtime.
//
// So, for instance, if a certain provider only works with PHP files, it
// should return its instance here no matter what, and that instance can
// respond to `canProvideSymbols` with `false` if the given editor isn't
// using a PHP grammar. It shouldn't try to get clever and bail out entirely
// if, say, the project doesn't have any PHP files on load — because, of
// course, it _could_ add a PHP file at any point, and we're not going to
// revisit the decision later.
//
// Likewise, if a provider depends upon a language server that may or may not
// be running, it should not try to be clever about what it returns from
// `provideSymbols`. Instead, it should return early from `canProvideSymbols`
// when the language server isn't running.
//
// A single package can supply multiple providers if need be.
//
provideSymbols(): SymbolProvider | SymbolProvider[],
};

View File

@ -0,0 +1,227 @@
const { Disposable } = require('atom');
const Config = require('./config');
const ProviderBroker = require('./provider-broker');
const Path = require('path');
const { shell } = require('electron');
const { migrateOldConfigIfNeeded } = require('./util');
const NO_PROVIDERS_MESSAGE = `You dont have any symbol providers installed.`;
const NO_PROVIDERS_DESCRIPTION = `The button below will show all packages that can provide symbols.
At minimum, we recommend you install the following packages to get an experience similar to that of the built-in \`symbols-view\` package:
* \`symbol-provider-tree-sitter\`
* \`symbol-provider-ctags\`
`;
const NO_PROVIDERS_BUTTONS = [
{
text: 'Find providers',
onDidClick: () => {
shell.openExternal(`https://web.pulsar-edit.dev/packages?service=symbol.provider&serviceType=provided`);
}
}
];
module.exports = {
activate() {
Config.activate();
this.stack = [];
this.broker = new ProviderBroker();
this.workspaceSubscription = atom.commands.add(
'atom-workspace',
{
'symbols-view:toggle-project-symbols': () => {
if (!this.ensureProvidersExist()) return;
this.createProjectView().toggle();
},
'symbols-view:show-active-providers': () => {
this.showActiveProviders();
}
}
);
this.editorSubscription = atom.commands.add(
'atom-text-editor:not([mini])',
{
'symbols-view:toggle-file-symbols': (e) => {
if (!this.ensureProvidersExist()) {
e.abortKeyBinding();
return;
}
this.createFileView().toggle();
},
'symbols-view:go-to-declaration': () => {
if (!this.ensureProvidersExist()) return;
this.createGoToView().toggle();
},
'symbols-view:return-from-declaration': () => {
if (!this.ensureProvidersExist()) return;
this.createGoBackView().toggle();
}
}
);
migrateOldConfigIfNeeded();
},
deactivate() {
this.fileView?.destroy();
this.fileView = null;
this.projectView?.destroy();
this.projectView = null;
this.goToView?.destroy();
this.goToView = null;
this.goBackView?.destroy();
this.goBackView = null;
this.workspaceSubscription?.dispose();
this.workspaceSubscription = null;
this.editorSubscription?.dispose();
this.editorSubscription = null;
this.broker?.destroy();
this.broker = null;
this.subscriptions?.dispose();
this.subscriptions = null;
},
consumeSymbolProvider(provider) {
if (Array.isArray(provider)) {
this.broker.add(...provider);
} else {
this.broker.add(provider);
}
return new Disposable(() => {
if (Array.isArray(provider)) {
this.broker.remove(...provider);
} else {
this.broker.remove(provider);
}
});
},
createFileView() {
if (this.fileView) return this.fileView;
const FileView = require('./file-view');
this.fileView = new FileView(this.stack, this.broker);
return this.fileView;
},
createProjectView() {
if (this.projectView) return this.projectView;
const ProjectView = require('./project-view');
this.projectView = new ProjectView(this.stack, this.broker);
return this.projectView;
},
createGoToView() {
if (this.goToView) return this.goToView;
const GoToView = require('./go-to-view');
this.goToView = new GoToView(this.stack, this.broker);
return this.goToView;
},
createGoBackView() {
if (this.goBackView) return this.goBackView;
const GoBackView = require('./go-back-view');
this.goBackView = new GoBackView(this.stack, this.broker);
return this.goBackView;
},
showActiveProviders() {
let providerList = [];
for (let provider of this.broker.providers) {
providerList.push({
name: provider.name,
packageName: provider.packageName
});
}
let message = providerList.map(
p => `* **${p.name}** provided by \`${p.packageName}\``
).join('\n');
atom.notifications.addInfo(
'Symbols View Redux providers',
{
description: message,
dismissable: true,
buttons: [
{
text: 'Copy',
onDidClick() {
atom.clipboard.write(message);
}
}
]
}
);
},
ensureProvidersExist() {
if (this.broker.providers.length > 0) return true;
atom.notifications.addWarning(
NO_PROVIDERS_MESSAGE,
{
description: NO_PROVIDERS_DESCRIPTION,
buttons: NO_PROVIDERS_BUTTONS,
dismissable: true
}
);
return false;
},
ensureActiveTextEditorExists() {
let editor = atom.workspace.getActiveTextEditor();
return !!editor;
},
// A `hyperclick` consumer that works similarly to the
// `symbols-view:go-to-definition` command.
provideHyperclick() {
return {
priority: 1,
getSuggestionForWord: async (editor, _text, range) => {
let goto = this.createGoToView();
let symbols = await goto.generateSymbols(editor, range);
let editorPath = editor.getPath();
if (!symbols || symbols.length === 0) return;
// If we're at the definition site, the only result will be a symbol
// whose position is identical to the position we asked about. Filter
// it out. In that situation, we don't want a hyperclick affordance at
// all.
symbols = symbols.filter(sym => {
let { path, directory, file } = sym;
if (!path) {
path = Path.join(directory, file);
}
return path !== editorPath || sym.position.compare(range.start) !== 0;
});
if (symbols.length === 0) return;
return {
range,
callback: () => {
editor.setSelectedBufferRange(range);
goto.toggle();
}
};
}
};
}
};

View File

@ -0,0 +1,198 @@
// const { Point } = require('atom');
const SymbolsView = require('./symbols-view');
const { isIterable, timeout } = require('./util');
module.exports = class ProjectView extends SymbolsView {
constructor (stack, broker) {
// TODO: Do these defaults make sense? Should we allow a provider to
// override them?
super(stack, broker, {
emptyMessage: 'Project has no symbols or is empty',
maxResults: 20,
isDynamic: true
});
this.shouldReload = true;
}
destroy () {
return super.destroy();
}
toggle () {
if (this.panel.isVisible()) {
this.cancel();
} else {
this.populate();
this.attach();
}
}
didCancelSelection () {
this.abortController?.abort();
super.didCancelSelection();
}
didConfirmEmptySelection () {
this.abortController?.abort();
super.didConfirmEmptySelection();
}
isValidSymbol (symbol) {
if (!super.isValidSymbol(symbol)) return false;
if (
!(typeof symbol.file === 'string' && typeof symbol.directory === 'string') &&
!(typeof symbol.path === 'string')
) {
return false;
}
return true;
}
shouldUseCache () {
let query = this.selectListView?.getQuery();
if (query && query.length > 0) return false;
if (this.shouldReload) return false;
return !!this.cachedSymbols;
}
didChangeQuery () {
this.populate({ retain: true });
}
clear () {
}
async populate ({ retain = false } = {}) {
if (this.shouldUseCache()) {
await this.updateView({ items: this.cachedSymbols });
return true;
}
let query = this.selectListView?.getQuery();
let listViewOptions = {
loadingMessage: this.cachedSymbols ?
`Reloading project symbols\u2026` :
`Loading project symbols\u2026`
};
if (!this.cachedSymbols) {
listViewOptions.loadingBadge = 0;
}
// await this.updateView(listViewOptions);
let editor = atom.workspace.getActiveTextEditor();
let start = performance.now();
this._lastTimestamp = start;
let anySymbolsLoaded = false;
let result = this.generateSymbols(editor, query, (symbols) => {
anySymbolsLoaded = symbols.length > 0;
// TODO: Should we sort by buffer position? Should we leave it up to the
// provider? Should we make it configurable?
let options = {
...listViewOptions,
items: symbols,
loadingMessage: null
};
this.updateView(options);
});
let loadingTimeout;
if (retain) {
loadingTimeout = setTimeout((timestamp) => {
if (timestamp !== this._lastTimestamp) return;
if (anySymbolsLoaded) return;
this.updateView({ loadingMessage: `Reloading project symbols\u2026` });
}, 500, start);
}
if (result?.then) result = await result;
clearTimeout(loadingTimeout);
if (result == null) {
result = [];
}
this.cachedSymbols = result;
// TODO: We assume that project-wide symbol search will involve re-querying
// the language server whenever the user types another character. This
// distinguishes it from searching within one buffer — where typing in the
// query field just filters a static list.
//
// This is a safe assumption to make, but we could at least make it
// possible for a provider to return a static list and somehow indicate
// that its static so that we dont have to keep re-querying.
this.shouldReload = true;
return true;
}
async generateSymbols (editor, query = '', callback) {
this.abortController?.abort();
this.abortController = new AbortController();
let meta = { type: 'project', editor, query };
// The signal is how a provider can stop doing work if it's going async,
// since it'll be able to tell if we've cancelled this command and no
// longer need the symbols we asked for.
let signal = this.abortController.signal;
let providers = await this.broker.select(meta);
if (providers?.length === 0) {
console.warn('No providers found!');
return null;
}
let allSymbols = [];
let done = (symbols, provider) => {
if (signal.aborted) return;
if (!isIterable(symbols)) {
error(`Provider did not return a list of symbols`, provider);
return;
}
this.addSymbols(allSymbols, symbols, provider);
callback(allSymbols);
};
let error = (err, provider) => {
if (signal.aborted) return;
let message = typeof err === 'string' ? err : err.message;
console.error(`Error in retrieving symbols from provider ${provider.name}: ${message}`);
};
let tasks = [];
for (let provider of providers) {
try {
let symbols = this.getSymbolsFromProvider(provider, signal, meta);
if (symbols?.then) {
let task = symbols
.then((result) => done(result, provider))
.catch(err => error(err, provider));
tasks.push(task);
} else if (isIterable(symbols)) {
done(symbols, provider);
} else {
error(`Provider did not return a list of symbols`, provider);
}
} catch (err) {
error(err, provider);
}
}
if (tasks.length > 0) {
await Promise.race([Promise.allSettled(tasks), timeout(this.timeoutMs)]);
}
// Since we might've gone async here, we should check our own signal. If
// it's aborted, that means the user has cancelled.
if (signal.aborted) return null;
this.cachedSymbols = allSymbols;
return allSymbols;
}
};

View File

@ -0,0 +1,244 @@
const { CompositeDisposable, Emitter } = require('atom');
const Config = require('./config');
/**
* An error thrown when a newly added symbol provider does not conform to its
* contract.
*
* @extends Error
*/
class InvalidProviderError extends Error {
constructor(faults, provider) {
let packageName = provider.packageName ?
`the ${provider.packageName} provider`
: 'a symbol provider';
let message = `symbols-view failed to consume ${packageName} because certain properties are invalid: ${faults.join(', ')}. Please fix these faults or contact the package author.`;
super(message);
this.name = 'InvalidProviderError';
}
}
/**
* A class that keeps track of various symbol providers and selects which ones
* should respond for a given request.
*
* @type {ProviderBroker}
* @class
*/
module.exports = class ProviderBroker {
constructor() {
this.providers = [];
this.providerSubscriptions = new Map();
this.subscriptions = new CompositeDisposable();
this.emitter = new Emitter();
}
/**
* Add one or more symbol providers.
*
* @param {SymbolProvider[]} providers Any number of symbol providers.
*/
add(...providers) {
for (let provider of providers) {
try {
this.validateSymbolProvider(provider);
} catch (err) {
console.warn(err.message);
continue;
}
this.providers.push(provider);
this.emitter.emit('did-add-provider', provider);
this.observeProvider(provider);
}
}
/**
* Remove one or more symbol providers.
*
* @param {SymbolProvider[]} providers Any number of symbol providers.
*/
remove(...providers) {
for (let provider of providers) {
let index = this.providers.indexOf(provider);
// Providers that were invalid may not have been added. Not a problem.
if (index === -1) continue;
this.providers.splice(index, 1);
this.emitter.emit('did-remove-provider', provider);
this.stopObservingProvider(provider);
}
}
/**
* Ensure a symbol provider abides by the contract.
*
* @param {SymbolProvider} provider A symbol provider to validate.
*/
validateSymbolProvider(provider) {
let faults = [];
if (typeof provider.name !== 'string') faults.push('name');
if (typeof provider.packageName !== 'string') faults.push('packageName');
if (typeof provider.canProvideSymbols !== 'function')
faults.push('canProvideSymbols');
if (typeof provider.getSymbols !== 'function')
faults.push('getSymbols');
if (faults.length > 0) {
throw new InvalidProviderError(faults, provider);
}
}
/**
* Observe a symbol provider so that we can clear our symbol cache if needed.
*
* @param {SymbolProvider} provider A symbol provider.
*/
observeProvider(provider) {
let disposable = new CompositeDisposable();
this.providerSubscriptions.set(provider, disposable);
// Providers can implement `onShouldClearCache` when they want to control
// when symbols they provide are no longer valid.
if (!provider.onShouldClearCache) return;
disposable.add(
provider.onShouldClearCache((bundle) => {
this.emitter.emit('should-clear-cache', { ...bundle, provider });
})
);
}
/**
* Stop observing a symbol provider.
*
* @param {SymbolProvider} provider A symbol provider.
*/
stopObservingProvider(provider) {
let disposable = this.providerSubscriptions.get(provider);
this.providerSubscriptions.delete(provider);
disposable?.dispose;
}
destroy() {
for (let provider of this.providers) {
provider?.destroy?.();
this.emitter.emit('did-remove-provider', provider);
}
}
onDidAddProvider(callback) {
return this.emitter.on('did-add-provider', callback);
}
onDidRemoveProvider(callback) {
return this.emitter.on('did-remove-provider', callback);
}
onShouldClearCache(callback) {
return this.emitter.on('should-clear-cache', callback);
}
/**
* Boost the relevance score of certain providers based on their position in
* the settings value. If there are 5 providers listed, the first one gets a
* five-point boost; the second a four-point boost; and so on.
*
* @param {String} name The provider's name.
* @param {String} packageName The provider's package name.
* @param {Array} preferredProviders A list of preferred providers from
* configuration; each entry can refer to a provider's name or its package
* name.
*
* @returns {Number} The amount by which to boost the provider's relevance
* score.
*/
getScoreBoost(name, packageName, preferredProviders = []) {
let shouldLog = Config.get('enableDebugLogging');
if (packageName === 'unknown') return 0;
let index = preferredProviders.indexOf(packageName);
if (index === -1) {
index = preferredProviders.indexOf(name);
}
if (index === -1) return 0;
let scoreBoost = preferredProviders.length - index;
if (shouldLog)
console.log('Score boost for provider', name, packageName, 'is', scoreBoost);
return scoreBoost;
}
/**
* Given metadata about a symbol request, choose the best provider(s) for the
* job.
*
* @param {SymbolMeta} meta Metadata about the symbol request.
*
* @returns {Promise<SymbolProvider[]>} A promise that resolves with a list
* of symbol providers.
*/
async select(meta) {
let shouldLog = Config.get('enableDebugLogging');
let exclusivesByScore = [];
let results = [];
let preferredProviders = atom.config.get('symbols-view.preferCertainProviders');
if (shouldLog) {
console.debug(`Provider broker choosing among ${this.providers.length} candidates:`, this.providers);
console.debug('Metadata is:', meta);
}
let answers = this.providers.map(provider => {
// TODO: This method can reluctantly go async because language clients
// might have to ask their servers about capabilities. We must introduce
// a timeout value here so that we don't wait indefinitely for providers
// to respond.
if (shouldLog)
console.debug(`Asking provider:`, provider.name, provider);
return provider.canProvideSymbols(meta);
// return timeout(provider.canProvideSymbols(meta), 500);
});
let outcomes = await Promise.allSettled(answers);
for (let [index, provider] of this.providers.entries()) {
let outcome = outcomes[index];
if (shouldLog)
console.debug(`Outcome for provider`, provider.name, 'is', outcome);
if (outcome.status === 'rejected') continue;
let { value: score } = outcome;
let name = provider.name ?? 'unknown';
let packageName = provider?.packageName ?? 'unknown';
let isExclusive = provider?.isExclusive ?? false;
if (shouldLog)
console.debug('Score for', provider.name, 'is:', score);
if (!score) continue;
if (score === true) score = 1;
score += this.getScoreBoost(name, packageName, preferredProviders);
if (isExclusive) {
// “Exclusive” providers get put aside until the end. We'll pick the
// _one_ that has the highest score.
exclusivesByScore.push({ provider, score });
} else {
// Non-exclusive providers go into the pile because we know we'll be
// using them all.
results.push(provider);
}
}
if (exclusivesByScore.length > 0) {
exclusivesByScore.sort((a, b) => b.score - a.score);
let exclusive = exclusivesByScore[0].provider;
results.unshift(exclusive);
}
if (shouldLog)
console.debug('Returned providers:', results);
return results;
}
};

View File

@ -0,0 +1,394 @@
const Path = require('path');
const fs = require('fs-plus');
const Config = require('./config');
const { CompositeDisposable, Point } = require('atom');
const SelectListView = require('atom-select-list');
const { match } = require('fuzzaldrin');
const el = require('./element-builder');
const { badge } = require('./util');
// Properties that we allow a provider to set on a `SelectListView` via a
// `ListController` instance.
const ALLOWED_PROPS_IN_LIST_CONTROLLER = new Set([
'errorMessage',
'emptyMessage',
'loadingMessage',
'loadingBadge'
]);
function validateListControllerProps(props) {
return Object.keys(props).every(k => (
ALLOWED_PROPS_IN_LIST_CONTROLLER.has(k)
));
}
/**
* A class for setting various UI properties on a symbol list palette. This is a
* privilege given to the main (or _exclusive_) provider for a given task.
*
* This is how we allow a provider to communicate its state to the UI without
* giving it full control over the `SelectListView` used to show results.
*/
class ListController {
constructor(view) {
this.view = view;
}
set(props) {
if (!validateListControllerProps(props)) {
console.warn('Provider gave invalid properties to symbol list UI:', props);
}
return this.view.update(props);
}
clear(...propNames) {
let props = {};
for (let propName of propNames) {
if (!ALLOWED_PROPS_IN_LIST_CONTROLLER.has(propName)) continue;
props[propName] = null;
}
return this.view.update(props);
}
}
class SymbolsView {
static highlightMatches(_context, name, matches, offsetIndex = 0) {
let lastIndex = 0;
let matchedChars = [];
const fragment = document.createDocumentFragment();
for (let matchIndex of [...matches]) {
matchIndex -= offsetIndex;
if (matchIndex < 0) continue;
let unmatched = name.substring(lastIndex, matchIndex);
if (unmatched) {
if (matchedChars.length) {
let span = document.createElement('span');
span.classList.add('character-match');
span.textContent = matchedChars.join('');
fragment.appendChild(span);
}
matchedChars = [];
fragment.appendChild(document.createTextNode(unmatched));
}
matchedChars.push(name[matchIndex]);
lastIndex = matchIndex + 1;
}
if (matchedChars.length) {
const span = document.createElement('span');
span.classList.add('character-match');
span.textContent = matchedChars.join('');
fragment.appendChild(span);
}
// Remaining characters are plain text.
fragment.appendChild(
document.createTextNode(name.substring(lastIndex))
);
return fragment;
}
constructor(stack, broker, options = {}) {
this.stack = stack;
this.broker = broker;
options = {
emptyMessage: 'No symbols found',
maxResults: null,
...options
};
this.selectListView = new SelectListView({
...options,
items: [],
filterKeyForItem: (item) => item.name,
elementForItem: this.elementForItem.bind(this),
didChangeQuery: this.didChangeQuery.bind(this),
didChangeSelection: this.didChangeSelection.bind(this),
didConfirmSelection: this.didConfirmSelection.bind(this),
didConfirmEmptySelection: this.didConfirmEmptySelection.bind(this),
didCancelSelection: this.didCancelSelection.bind(this)
});
this.selectListViewOptions = options;
this.listController = new ListController(this.selectListView);
this.element = this.selectListView.element;
this.element.classList.add('symbols-view');
this.panel = atom.workspace.addModalPanel({ item: this, visible: false });
this.configDisposable = new CompositeDisposable();
this.configDisposable.add(
atom.config.observe(
`symbols-view`,
(value) => {
this.shouldShowProviderName = value.showProviderNamesInSymbolsView;
this.useBadgeColors = value.useBadgeColors;
}
),
Config.observe('providerTimeout', (ms) => this.timeoutMs = ms),
Config.observe('showIconsInSymbolsView', (show) => this.showIconsInSymbolsView = show)
);
}
async destroy() {
await this.cancel();
this.configDisposable.dispose();
this.panel.destroy();
return this.selectListView.destroy();
}
getFilterKey() {
return 'name';
}
elementForItem({ position, name, file, icon, tag, context, directory, providerName }) {
name = name.replace(/\n/g, ' ');
if (atom.project.getPaths().length > 1) {
// More than one project root — we need to disambiguate the file paths.
file = Path.join(Path.basename(directory), file);
}
let badges = [];
if (providerName && this.shouldShowProviderName) {
badges.push(providerName);
}
if (tag) badges.push(tag);
let primaryLineClasses = ['primary-line'];
if (this.showIconsInSymbolsView) {
if (icon) {
primaryLineClasses.push('icon', icon);
} else {
primaryLineClasses.push('no-icon');
}
}
let matches = match(name, this.selectListView.getFilterQuery());
let primary = el(`div.${primaryLineClasses.join('.')}`,
el('div.name',
SymbolsView.highlightMatches(this, name, matches)
),
badges && el('div.badge-container',
...badges.map(
b => badge(b, { variant: this.useBadgeColors })
)
)
);
let secondaryLineClasses = ['secondary-line'];
if (this.showIconsInSymbolsView) {
secondaryLineClasses.push('no-icon');
}
let secondary = el(`div.${secondaryLineClasses.join('.')}`,
el('span.location',
position ? `${file}:${position.row + 1}` : file
),
context ? el('span.context', context) : null
);
return el('li.two-lines', primary, secondary);
}
async cancel() {
if (!this.isCanceling) {
this.isCanceling = true;
await this.updateView({ items: [] });
this.panel.hide();
if (this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
this.previouslyFocusedElement = null;
}
this.isCanceling = false;
}
}
async updateView(options) {
this.selectListView.update(options);
}
didChangeQuery() {
// no-op
}
didCancelSelection() {
this.cancel();
}
didConfirmEmptySelection() {
this.cancel();
}
async didConfirmSelection(tag) {
if (tag.file && !fs.isFileSync(Path.join(tag.directory, tag.file))) {
await this.updateView({
errorMessage: `Selected file does not exist`
});
setTimeout(() => {
this.updateView({ errorMessage: null });
}, 2000);
} else {
await this.cancel();
this.openTag(tag, { pending: this.shouldBePending() });
}
}
// Whether a pane opened by a view should be treated as a pending pane.
shouldBePending() {
return false;
}
didChangeSelection() {
// no-op
}
openTag(tag, { pending } = {}) {
pending ??= this.shouldBePending();
let editor = atom.workspace.getActiveTextEditor();
let previous;
if (editor) {
previous = {
editorId: editor.id,
position: editor.getCursorBufferPosition(),
file: editor.getURI()
};
}
let { position, range } = tag;
if (!position) position = this.getTagLine(tag);
let result = false;
if (tag.file) {
// Open a different file, then jump to a position.
atom.workspace.open(
Path.join(tag.directory, tag.file),
{ pending, activatePane: false }
).then(() => {
if (position) {
return this.moveToPosition(position, { range });
}
return undefined;
});
result = true;
} else if (position && previous && !previous.position.isEqual(position)) {
// Jump to a position in the same file.
this.moveToPosition(position, { range });
result = true;
}
if (result) this.stack.push(previous);
return result;
}
moveToPosition(position, { beginningOfLine = true } = {}) {
let editor = atom.workspace.getActiveTextEditor();
if (editor) {
editor.setCursorBufferPosition(position, { autoscroll: false });
if (beginningOfLine) {
editor.moveToFirstCharacterOfLine();
}
editor.scrollToCursorPosition({ center: true });
}
}
attach() {
this.previouslyFocusedElement = document.activeElement;
this.panel.show();
this.selectListView.reset();
this.selectListView.focus();
}
isValidSymbol(symbol) {
if (typeof symbol.name !== 'string') return false;
if (!symbol.position && !symbol.range) return false;
if (symbol.position && !(symbol.position instanceof Point)) {
return false;
}
return true;
}
addSymbols(allSymbols, newSymbols, provider) {
for (let symbol of newSymbols) {
if (!this.isValidSymbol(symbol)) {
console.warn('Invalid symbol:', symbol);
continue;
}
// We enforce these so that (a) we can show a human-readable name of the
// provider for each symbol (if the user opts into it), and (b) we can
// selectively clear cached results for certain providers without
// affecting others.
symbol.providerName ??= provider.name;
symbol.providerId ??= provider.packageName;
if (symbol.path) {
let parts = Path.parse(symbol.path);
symbol.directory = `${parts.dir}${Path.sep}`;
symbol.file = parts.base;
}
symbol.name = symbol.name.replace(/[\n\r\t]/, ' ');
allSymbols.push(symbol);
}
}
// TODO: What on earth is this? Can we possibly still need it?
getTagLine(tag) {
if (!tag) return undefined;
if (tag.lineNumber) {
return new Point(tag.lineNumber - 1, 0);
}
if (!tag.pattern) return undefined;
let pattern = tag.pattern.replace(/(^\/\^)|(\$\/$)/g, '').trim();
if (!pattern) return undefined;
const file = Path.join(tag.directory, tag.file);
if (!fs.isFileSync(file)) return undefined;
let iterable = fs.readFileSync(file, 'utf8').split('\n');
for (let index = 0; index < iterable.length; index++) {
let line = iterable[index];
if (pattern === line.trim()) {
return new Point(index, 0);
}
}
return undefined;
}
getSymbolsFromProvider(provider, signal, meta) {
let controller = new AbortController();
// If the user cancels the task, propagate that cancellation to this
// provider's AbortController.
signal.addEventListener('abort', () => controller.abort());
// Cancel this job automatically if it times out.
setTimeout(() => controller.abort(), this.timeoutMs);
if (provider.isExclusive) {
// The exclusive provider is the only one that gets an instance of
// `ListController` so that it can set UI messages.
return provider.getSymbols(
{ signal: controller.signal, ...meta },
this.listController
);
} else {
return provider.getSymbols(
{ signal: controller.signal, ...meta }
);
}
}
}
module.exports = SymbolsView;

View File

@ -0,0 +1,161 @@
const el = require('./element-builder');
const murmur = require('murmurhash-js');
const BADGE_TEXT_HASH_MAP = new Map();
/**
* Ensures an object can be iterated over.
*
* The contract with the symbol providers is that they return an object that
* gives us symbol objects when we iterate over it. It'll probably be an array,
* but we're cool with anything iterable.
*
* @param {?} obj Anything.
* @returns {Boolean} Whether the item will respond correctly to a `for..of`
* loop.
*/
function isIterable(obj) {
if (obj === null || obj === undefined) return false;
return typeof obj[Symbol.iterator] === 'function';
}
/**
* Returns a promise that resolves after a given number of milliseconds.
* @param {Number} ms Number of milliseconds after which to resolve.
* @returns {Promise<true>} A promise that resolves with `true` as its argument.
*/
function timeout(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
/**
* Given a string of text, returns a hexadecimal character from `0` to `f` to
* represent a classification bucket. This is used when assigning colors to
* various symbol badges.
*
* @param {String} text The text of the badge.
* @returns {String} A single character that represents a hexadecimal digit.
*/
function getBadgeTextVariant(text) {
// The goal here is to give each tag a color such that (a) two things with the
// same tag will have badges of identical colors, and (b) two things with
// different tags are very likely to have badges of different colors. We use a
// fast (non-cryptographic) hashing algorithm, convert its return integer to
// hex, then take the final character; this, in effect, gives a piece of text
// an equal chance of being sorted into any of sixteen random buckets.
//
// In the CSS, we generate sixteen badge colors based on the user's UI theme;
// they are identical in saturation and brightness and vary only in hue.
if (BADGE_TEXT_HASH_MAP.has(text)) {
return BADGE_TEXT_HASH_MAP.get(text);
}
let hash = murmur.murmur3(text, 'symbols-view').toString(16);
let variantType = hash.charAt(hash.length - 1);
BADGE_TEXT_HASH_MAP.set(text, variantType);
return variantType;
}
/**
* Return a DOM element for a badge for a given symbol tag name.
*
* @param {String} text The text of the tag.
* @param {Object} options Options. Defaults to an empty object.
* @param {Boolean} options.variant Whether to add a class name for the badge's
* variant. If enabled, this will attempt to assign a different badge color
* for each kind of tag. Optional; defaults to `false`.
* @returns {Element} An element for adding to an `atom-select-view` entry.
*/
function badge(text, options = {}) {
let { variant = false } = options;
let classNames = `.badge.badge-info.badge-flexible.badge-symbol-tag`;
if (variant) {
let variantType = getBadgeTextVariant(text);
classNames += `.symbols-view-badge-variant-${variantType}`;
}
return el(`span${classNames}`, text);
}
const MIGRATED_SETTINGS_MESSAGE = `The \`symbols-view\` package has migrated the setting \`symbols-view.useEditorGrammarAsCtagsLanguage\` to its new location inside the core package \`symbol-provider-ctags\`. If you have defined any scope-specific overrides for this setting, youll need to change those overrides manually.`;
// TODO: This function performs a one-time config migration. We can remove this
// chore in the future when we feel like it's served its purpose.
function migrateOldConfigIfNeeded({ force = false } = {}) {
if (!force) {
// Look up the schema for `symbols-view` and make sure that the deprecated
// setting isn't present.
let schema = atom.config.getSchema('symbols-view');
if (schema?.type === 'any') {
// We might be in a testing environment.
return;
}
if (!schema.properties || ('useEditorGrammarAsCtagsLanguage' in schema.properties)) {
// This means the setting is still expected as part of `symbols-view` and
// should not be migrated.
return;
}
}
// If we get this far, we know that any
// `symbols-view.useEditorGrammarAsCtagsLanguage` setting we find should be
// migrated to `symbol-provider-ctags.useEditorGrammarAsCtagsLanguage`.
// We're only interested in the value we get directly from the config file.
let settings = atom.config.get(
'symbols-view',
{ sources: [atom.config.getUserConfigPath()] }
) || {};
if ('useEditorGrammarAsCtagsLanguage' in settings) {
atom.config.set(
'symbol-provider-ctags.useEditorGrammarAsCtagsLanguage',
settings.useEditorGrammarAsCtagsLanguage
);
atom.config.unset('symbols-view.useEditorGrammarAsCtagsLanguage');
}
// 99% of users won't care that we've migrated this setting, but the other 1%
// would appreciate a heads-up. So let's try to detect any scope-specific
// overrides that have touched this setting. We won't attempt to migrate
// those automatically, but we'll at least let the user know that they might
// have to be changed.
//
// This reaches into the private implementation of `atom.config`, so let's
// exit gracefully if this fails.
let propertySets = atom.config?.scopedSettingsStore.propertySets;
if (!propertySets) return;
let sources = [];
for (let { properties, source } of propertySets) {
if (
properties['symbols-view'] &&
('useEditorGrammarAsCtagsLanguage' in properties['symbols-view'])
) {
sources.push(source);
}
}
if (sources.length > 0) {
sources = Array.from(new Set(sources));
let overrideSources = sources.map(s => `* \`${s}\``).join('\n');
atom.notifications.addInfo(
`Setting migrated`,
{
description: `${MIGRATED_SETTINGS_MESSAGE}
Detected overrides in the following locations:
${overrideSources}`,
dismissable: true
}
);
}
}
module.exports = {
badge,
isIterable,
migrateOldConfigIfNeeded,
timeout
};

View File

@ -0,0 +1,78 @@
{
"name": "symbols-view",
"version": "1.0.0",
"main": "./lib/main",
"types": "./lib/main.d.ts",
"description": "Jump to a function/method in the current editor or in the project.",
"repository": "https://github.com/pulsar-edit/pulsar",
"engines": {
"atom": "*",
"node": ">=14"
},
"configSchema": {
"quickJumpToFileSymbol": {
"default": true,
"type": "boolean",
"description": "Automatically visit selected file-symbols as you navigate the symbols list."
},
"showProviderNamesInSymbolsView": {
"default": false,
"type": "boolean",
"description": "When enabled, the name of the provider will be shown alongside each result."
},
"showIconsInSymbolsView": {
"default": true,
"type": "boolean",
"description": "When enabled, an icon will be shown alongside a symbol if the symbol provider specifies one."
},
"preferCertainProviders": {
"default": [],
"type": "array",
"items": {
"type": "string"
},
"description": "A comma-separated list of preferred providers. Used to help break ties when more than one provider can contribute symbols. Anything on this list will be preferred over anything not on this list, and earlier items will be preferred over later items. (A provider can be identified by its official name or its package name; run the **Symbols View: Show Active Providers** command to see both values.)"
},
"useBadgeColors": {
"default": false,
"type": "boolean",
"description": "Whether to use an assortment of colors for symbol badges. If enabled, each badge will be one of sixteen colors based on its text. Badge colors are generated automatically as hue variants of your themes ordinary badge color."
},
"providerTimeout": {
"default": 2000,
"type": "number",
"description": "How long providers have to respond to symbol requests before this package gives up and shows the list. If a certain provider is particularly slow, you may have to increase this value. (Does not apply to project-wide symbol search **if** the list is already visible.)"
},
"enableDebugLogging": {
"default": false,
"type": "boolean",
"description": "Whether to log certain diagnostic information to the console. (For example: which provider is chosen for a given task.)"
}
},
"consumedServices": {
"symbol.provider": {
"description": "Allows external sources to suggest symbols for a given file or project.",
"versions": {
"1.0.0": "consumeSymbolProvider"
}
}
},
"providedServices": {
"hyperclick": {
"versions": {
"0.1.0": "provideHyperclick"
}
}
},
"license": "MIT",
"dependencies": {
"atom-select-list": "^0.8.1",
"fs-plus": "^3.1.1",
"fuzzaldrin": "^2.1.0",
"murmurhash-js": "^1.0.0"
},
"devDependencies": {
"eslint": "^8.39.0",
"temp": "^0.9.4"
}
}

View File

@ -0,0 +1,10 @@
module.exports = {
env: { jasmine: true },
rules: {
"node/no-unpublished-require": "off",
"node/no-extraneous-require": "off",
"no-unused-vars": "off",
"no-empty": "off",
"no-constant-condition": "off"
}
};

View File

@ -0,0 +1,67 @@
/** @babel */
export function beforeEach (fn) {
global.beforeEach(function () {
const result = fn();
if (result instanceof Promise) {
waitsForPromise(() => result);
}
});
}
export function afterEach (fn) {
global.afterEach(function () {
const result = fn();
if (result instanceof Promise) {
waitsForPromise(() => result);
}
});
}
['it', 'fit', 'ffit', 'fffit'].forEach(function (name) {
module.exports[name] = function (description, fn) {
global[name](description, function () {
const result = fn();
if (result instanceof Promise) {
waitsForPromise(() => result);
}
});
};
});
export async function conditionPromise (condition) {
const startTime = Date.now();
while (true) {
await timeoutPromise(100);
let conditionResult = condition();
if (conditionResult instanceof Promise) {
conditionResult = await conditionResult;
}
if (conditionResult) {
return;
}
if (Date.now() - startTime > 5000) {
throw new Error('Timed out waiting on condition');
}
}
}
export function timeoutPromise (timeout) {
return new Promise(function (resolve) {
global.setTimeout(resolve, timeout);
});
}
function waitsForPromise (fn) {
const promise = fn();
global.waitsFor('spec promise to resolve', function (done) {
promise.then(done, function (error) {
jasmine.getEnv().currentSpec.fail(error);
done();
});
});
}

View File

@ -0,0 +1,29 @@
// Another file for symbols to exist in. Used for project search.
var quicksort = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};
var quicksort2 = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,13 @@
var quicksort = function () {
var sort = function (items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,11 @@
var thisIsCrazy = true;
function callMeMaybe () {
return "here's my number";
}
var iJustMetYou = callMeMaybe();
function duplicate () {
return true;
}

View File

@ -0,0 +1,60 @@
const { Point } = require('atom');
// const path = require('path');
function last (arr) {
return arr[arr.length - 1];
}
async function wait (ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const ICONS = [
'icon-package',
'icon-key',
'icon-gear',
'icon-tag',
null
];
module.exports = {
packageName: 'symbol-provider-dummy-async',
name: 'Dummy (Async)',
isExclusive: true,
canProvideSymbols (_meta) {
return true;
},
async getSymbols (meta, listController) {
let { editor, type } = meta;
let results = [];
if (type === 'file') {
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
icon: ICONS[(i / 3) % (ICONS.length + 1)]
});
}
} else if (type === 'project') {
let root = last(atom.project.getPaths());
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
directory: root,
file: 'other-file.js',
icon: ICONS[i % (ICONS.length + 1)]
});
}
}
await wait(100);
listController.set({ loadingMessage: 'Loading…' });
await wait(250);
listController.clear('loadingMessage');
return results;
}
};

View File

@ -0,0 +1,45 @@
const { Emitter, Point } = require('atom');
// const path = require('path');
function last (arr) {
return arr[arr.length - 1];
}
module.exports = {
packageName: 'symbol-provider-cache-clearing',
name: 'Cache-clearing',
isExclusive: false,
canProvideSymbols (_meta) {
return true;
},
onShouldClearCache (callback) {
this.emitter ??= new Emitter;
return this.emitter.on('should-clear-cache', callback);
},
getSymbols (meta) {
let { editor, type } = meta;
let results = [];
setTimeout(() => {
this.emitter.emit('should-clear-cache', { editor });
}, 0);
if (type === 'file') {
results = [{
position: new Point(1, 0),
name: 'Transient symbol on row 2'
}];
} else if (type === 'project') {
let root = last(atom.project.getPaths());
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
directory: root,
file: 'other-file.js'
});
}
}
return results;
}
};

View File

@ -0,0 +1,41 @@
const { Point } = require('atom');
function last (arr) {
return arr[arr.length - 1];
}
module.exports = {
packageName: 'symbol-provider-competing-exclusive',
name: 'Competing Exclusive',
isExclusive: true,
canProvideSymbols () {
return 0.9;
},
getSymbols (meta) {
let { editor, type } = meta;
let results = [];
if (type === 'file') {
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`
});
}
} else if (type === 'project') {
let root = last(atom.project.getPaths());
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
directory: root,
file: 'other-file.js'
});
}
}
return results;
}
};

View File

@ -0,0 +1,51 @@
const { Point } = require('atom');
function last (arr) {
return arr[arr.length - 1];
}
const ICONS = [
'icon-package',
'icon-key',
'icon-gear',
'icon-tag',
null
];
module.exports = {
packageName: 'symbol-provider-dummy',
name: 'Dummy',
isExclusive: true,
canProvideSymbols () {
return true;
},
getSymbols (meta) {
let { editor, type } = meta;
let results = [];
if (type === 'file') {
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
icon: ICONS[(i / 3) % (ICONS.length + 1)]
});
}
} else if (type === 'project') {
let root = last(atom.project.getPaths());
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
directory: root,
file: 'other-file.js',
icon: ICONS[i % (ICONS.length + 1)]
});
}
}
return results;
}
};

View File

@ -0,0 +1,13 @@
const { Point } = require('atom');
module.exports = {
packageName: 'symbol-provider-empty',
name: 'Empty',
isExclusive: false,
canProvideSymbols (meta) {
return true;
},
getSymbols (meta) {
return [];
}
};

View File

@ -0,0 +1,57 @@
const { Point } = require('atom');
function last (arr) {
return arr[arr.length - 1];
}
const SYMBOLS = [
{
name: 'Lorem ipsum',
position: new Point(0, 0),
file: 'other-file.js',
},
{
name: 'Loyalty',
position: new Point(1, 0),
file: 'other-file.js',
},
{
name: 'Lox',
position: new Point(2, 0),
file: 'other-file.js',
},
{
name: 'Lo mein',
position: new Point(3, 0),
file: 'other-file.js',
}
];
module.exports = {
packageName: 'symbol-provider-progressive',
name: 'Progressive Project Provider',
isExclusive: true,
canProvideSymbols (meta) {
return meta.type === 'project';
},
getSymbols (meta, controller) {
let root = last(atom.project.getPaths());
let { type, query = '' } = meta;
if (type !== 'project') return [];
// Simulate a provider that requires a minimum character count.
if (query.length < 3) {
controller.set({ emptyMessage: 'Query must be at least 3 characters long.' });
return [];
} else {
controller.clear('emptyMessage');
}
let results = SYMBOLS.filter(s => {
let term = s.name.toLowerCase();
return term.startsWith(query);
});
return results.map(r => ({ ...r, directory: root }));
}
};

View File

@ -0,0 +1,44 @@
const { Point } = require('atom');
const path = require('path');
function last (arr) {
return arr[arr.length - 1];
}
module.exports = {
packageName: 'symbol-provider-quicksort',
name: 'Quicksort',
isExclusive: true,
canProvideSymbols (meta) {
return true;
},
getSymbols (meta) {
let { editor, type } = meta;
let results = [];
if (type === 'file') {
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
let name = `Symbol on Row ${i + 1}`;
if (i === 0) name = 'quicksort';
results.push({
position: new Point(i, 0),
name
});
}
} else if (type === 'project') {
let root = last(atom.project.getPaths());
let count = editor.getLineCount();
// Put a symbol on every third line.
for (let i = 0; i < count; i += 3) {
results.push({
position: new Point(i, 0),
name: `Symbol on Row ${i + 1}`,
directory: root,
file: 'other-file.js'
});
}
}
return results;
}
};

View File

@ -0,0 +1,44 @@
const { Point } = require('atom');
const path = require('path');
function last (arr) {
return arr[arr.length - 1];
}
const MOCK_FILE_NAME = 'tagged.js';
const MOCK_RESULT_COUNT = 1;
module.exports = {
// If you change these values, you MUST remember to call `reset` in an
// `afterEach` block!
mockResultCount: MOCK_RESULT_COUNT,
mockFileName: MOCK_FILE_NAME,
reset () {
this.mockFileName = MOCK_FILE_NAME;
this.mockResultCount = MOCK_RESULT_COUNT;
},
packageName: 'symbol-provider-tagged',
name: 'Tagged',
isExclusive: true,
canProvideSymbols (meta) {
if (!meta.type === 'project') return false;
return true;
},
getSymbols (meta) {
let root = last(atom.project.getPaths());
let { editor, type } = meta;
let results = [];
if (!type.includes('project')) return [];
for (let i = 0; i < this.mockResultCount; i++) {
results.push({
directory: root,
file: this.mockFileName,
position: new Point(2 + i, 10),
name: 'callMeMaybe'
});
}
return results;
}
};

View File

@ -0,0 +1,13 @@
const { Point } = require('atom');
module.exports = {
packageName: 'symbol-provider-useless',
name: 'Useless',
isExclusive: false,
canProvideSymbols (meta) {
return false;
},
getSymbols (meta) {
return null;
}
};

View File

@ -0,0 +1,29 @@
const { Point } = require('atom');
function wait (ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
module.exports = {
packageName: 'symbol-provider-very-slow',
name: 'Very Slow',
isExclusive: false,
canProvideSymbols () {
return true;
},
async getSymbols (meta) {
let { signal } = meta;
await wait(3000);
if (signal.aborted) {
return null;
}
return [
{
position: new Point(0, 0),
name: `Slow Symbol on Row 1`
}
];
}
};

View File

@ -0,0 +1,780 @@
const path = require('path');
const etch = require('etch');
const fs = require('fs-plus');
const temp = require('temp');
const SymbolsView = require('../lib/symbols-view');
const { migrateOldConfigIfNeeded } = require('../lib/util');
const DummyProvider = require('./fixtures/providers/dummy-provider');
const AsyncDummyProvider = require('./fixtures/providers/async-provider');
const ProgressiveProjectProvider = require('./fixtures/providers/progressive-project-provider.js');
const QuicksortProvider = require('./fixtures/providers/quicksort-provider.js');
const VerySlowProvider = require('./fixtures/providers/very-slow-provider');
const UselessProvider = require('./fixtures/providers/useless-provider.js');
const EmptyProvider = require('./fixtures/providers/empty-provider.js');
const TaggedProvider = require('./fixtures/providers/tagged-provider.js');
const CacheClearingProvider = require('./fixtures/providers/cache-clearing-provider.js');
const CompetingExclusiveProvider = require('./fixtures/providers/competing-exclusive-provider.js');
const { it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise } = require('./async-spec-helpers');
async function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getOrScheduleUpdatePromise() {
return new Promise((resolve) => etch.getScheduler().updateDocument(resolve));
}
function choiceCount(symbolsView) {
return symbolsView.element.querySelectorAll('li').length;
}
function getWorkspaceView() {
return atom.views.getView(atom.workspace);
}
function getEditorView() {
return atom.views.getView(atom.workspace.getActiveTextEditor());
}
function getSymbolsView() {
return atom.workspace.getModalPanels()[0]?.item;
}
async function dispatchAndWaitForChoices(commandName) {
atom.commands.dispatch(getEditorView(), commandName);
let symbolsView = atom.workspace.getModalPanels()[0].item;
await conditionPromise(() => {
let count = symbolsView.element.querySelectorAll('li').length;
return count > 0;
});
}
function registerProvider(...args) {
let pkg = atom.packages.getActivePackage('symbols-view');
let main = pkg?.mainModule;
if (!main) {
let disposable = atom.packages.onDidActivatePackage(pack => {
if (pack.name !== 'symbols-view') return;
for (let provider of args) {
pack.mainModule.consumeSymbolProvider(provider);
}
disposable.dispose();
});
// If we let the package lazy-activate the first time a command is invoked,
// we lose an opportunity to add mock providers. So we should activate it
// manually.
atom.packages.getLoadedPackage('symbols-view').activateNow();
} else {
for (let provider of args) {
main.consumeSymbolProvider(provider);
}
}
}
describe('SymbolsView', () => {
let symbolsView, activationPromise, editor, directory, mainModule;
beforeEach(async () => {
jasmine.unspy(Date, 'now');
jasmine.unspy(global, 'setTimeout');
atom.project.setPaths([
temp.mkdirSync('other-dir-'),
temp.mkdirSync('atom-symbols-view-')
]);
directory = atom.project.getDirectories()[1];
fs.copySync(
path.join(__dirname, 'fixtures', 'js'),
atom.project.getPaths()[1]
);
atom.config.set('symbols-view.showProviderNamesInSymbolsView', false);
atom.config.set('symbols-view.showIconsInSymbolsView', false);
activationPromise = atom.packages.activatePackage('symbols-view');
activationPromise.then(() => {
mainModule = atom.packages.getActivePackage('symbols-view').mainModule;
});
jasmine.attachToDOM(getWorkspaceView());
});
afterEach(async () => {
await atom.packages.deactivatePackage('symbols-view');
});
describe('when toggling file symbols', () => {
beforeEach(async () => {
atom.config.set('symbols-view.providerTimeout', 500);
await atom.workspace.open(directory.resolve('sample.js'));
});
it('displays all symbols with line numbers', async () => {
registerProvider(DummyProvider);
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
expect(symbolsView.selectListView.refs.loadingMessage).toBeUndefined();
expect(document.body.contains(symbolsView.element)).toBe(true);
expect(symbolsView.element.querySelectorAll('li').length).toBe(5);
expect(symbolsView.element.querySelector('li:first-child .primary-line')).toHaveText('Symbol on Row 1');
expect(symbolsView.element.querySelector('li:first-child .secondary-line')).toHaveText('Line 1');
expect(symbolsView.element.querySelector('li:last-child .primary-line')).toHaveText('Symbol on Row 13');
expect(symbolsView.element.querySelector('li:last-child .secondary-line')).toHaveText('Line 13');
// No icon-related classes should be added when `showIconsInSymbolsView`
// is false.
expect(symbolsView.element.querySelector('li:first-child .primary-line').classList.contains('icon')).toBe(false);
expect(symbolsView.element.querySelector('li:first-child .primary-line').classList.contains('no-icon')).toBe(false);
});
it('does not wait for providers that take too long', async () => {
registerProvider(DummyProvider, VerySlowProvider);
await activationPromise;
expect(mainModule.broker.providers.length).toBe(2);
atom.commands.dispatch(getEditorView(), 'symbols-view:toggle-file-symbols');
symbolsView = atom.workspace.getModalPanels()[0].item;
await conditionPromise(async () => {
await getOrScheduleUpdatePromise();
let count = symbolsView.element.querySelectorAll('li').length;
return count > 0;
});
expect(symbolsView.selectListView.refs.loadingMessage).toBeUndefined();
expect(document.body.contains(symbolsView.element)).toBe(true);
expect(symbolsView.element.querySelectorAll('li').length).toBe(5);
expect(symbolsView.element.querySelector('li:first-child .primary-line')).toHaveText('Symbol on Row 1');
expect(symbolsView.element.querySelector('li:first-child .secondary-line')).toHaveText('Line 1');
expect(symbolsView.element.querySelector('li:last-child .primary-line')).toHaveText('Symbol on Row 13');
expect(symbolsView.element.querySelector('li:last-child .secondary-line')).toHaveText('Line 13');
});
it('allows the exclusive provider to control certain UI aspects', async () => {
registerProvider(AsyncDummyProvider);
await activationPromise;
expect(mainModule.broker.providers.length).toBe(1);
atom.commands.dispatch(getEditorView(), 'symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
spyOn(symbolsView.selectListView, 'update').andCallThrough();
await conditionPromise(async () => {
await getOrScheduleUpdatePromise();
let count = symbolsView.element.querySelectorAll('li').length;
return count > 0;
});
expect(symbolsView.selectListView.update).toHaveBeenCalledWith(
{ loadingMessage: 'Loading…' }
);
});
it('caches tags until the editor changes', async () => {
registerProvider(DummyProvider);
await activationPromise;
editor = atom.workspace.getActiveTextEditor();
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = atom.workspace.getModalPanels()[0].item;
await symbolsView.cancel();
spyOn(DummyProvider, 'getSymbols').andCallThrough();
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
expect(choiceCount(symbolsView)).toBe(5);
expect(DummyProvider.getSymbols).not.toHaveBeenCalled();
await symbolsView.cancel();
await editor.save();
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
expect(symbolsView.selectListView.refs.loadingMessage).toBeUndefined();
expect(choiceCount(symbolsView)).toBe(5);
expect(DummyProvider.getSymbols).toHaveBeenCalled();
editor.destroy();
expect(symbolsView.cachedResults.get(editor)).toBeUndefined();
});
it("invalidates a single provider's tags if the provider asks it to", async () => {
registerProvider(DummyProvider, CacheClearingProvider);
await activationPromise;
editor = atom.workspace.getActiveTextEditor();
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = atom.workspace.getModalPanels()[0].item;
expect(choiceCount(symbolsView)).toBe(6);
await symbolsView.cancel();
await wait(100);
spyOn(DummyProvider, 'getSymbols').andCallThrough();
spyOn(CacheClearingProvider, 'getSymbols').andCallThrough();
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
expect(choiceCount(symbolsView)).toBe(6);
expect(DummyProvider.getSymbols).not.toHaveBeenCalled();
expect(CacheClearingProvider.getSymbols).toHaveBeenCalled();
await symbolsView.cancel();
await editor.save();
expect(symbolsView.cachedResults.get(editor)).toBeUndefined();
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
expect(symbolsView.selectListView.refs.loadingMessage).toBeUndefined();
expect(choiceCount(symbolsView)).toBe(6);
expect(DummyProvider.getSymbols).toHaveBeenCalled();
expect(CacheClearingProvider.getSymbols).toHaveBeenCalled();
editor.destroy();
expect(symbolsView.cachedResults.get(editor)).toBeUndefined();
});
it('displays a message when no tags match text in mini-editor', async () => {
registerProvider(DummyProvider);
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.refs.queryEditor.setText('nothing will match this');
await conditionPromise(() => symbolsView.selectListView.refs.emptyMessage);
expect(document.body.contains(symbolsView.element)).toBe(true);
expect(choiceCount(symbolsView)).toBe(0);
expect(symbolsView.selectListView.refs.emptyMessage.textContent.length).toBeGreaterThan(0);
symbolsView.selectListView.refs.queryEditor.setText('');
await conditionPromise(() => choiceCount(symbolsView) > 0);
expect( choiceCount(symbolsView) ).toBe(5);
expect(symbolsView.selectListView.refs.emptyMessage).toBeUndefined();
});
it('moves the cursor to the selected function', async () => {
registerProvider(DummyProvider);
await activationPromise;
editor = atom.workspace.getActiveTextEditor();
expect(editor.getCursorBufferPosition()).toEqual([0, 0]);
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.element.querySelectorAll('li')[1].click();
// It'll move to the first non-whitespace character on the line.
expect(editor.getCursorBufferPosition()).toEqual([3, 4]);
});
describe('when there are multiple exclusive providers', () => {
describe("and none have priority in the user's settings", () => {
it('prefers the one with the highest score', async () => {
registerProvider(DummyProvider, CompetingExclusiveProvider);
spyOn(CompetingExclusiveProvider, 'getSymbols').andCallThrough();
spyOn(DummyProvider, 'getSymbols').andCallThrough();
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
expect(choiceCount(symbolsView)).toBe(5);
expect(DummyProvider.getSymbols).toHaveBeenCalled();
expect(CompetingExclusiveProvider.getSymbols).not.toHaveBeenCalled();
});
});
describe('and one is listed in `preferCertainProviders`', () => {
beforeEach(() => {
atom.config.set('symbols-view.preferCertainProviders', ['symbol-provider-competing-exclusive']);
});
it('prefers the one with the highest score (providers listed beating those not listed)', async () => {
registerProvider(DummyProvider, CompetingExclusiveProvider);
spyOn(CompetingExclusiveProvider, 'getSymbols').andCallThrough();
spyOn(DummyProvider, 'getSymbols').andCallThrough();
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
expect(choiceCount(symbolsView)).toBe(5);
expect(DummyProvider.getSymbols).not.toHaveBeenCalled();
expect(CompetingExclusiveProvider.getSymbols).toHaveBeenCalled();
});
});
describe('and more than one is listed in `preferCertainProviders`', () => {
beforeEach(() => {
// Last time we referred to this one by its package name; now we use
// its human-friendly name. They should be interchangeable.
atom.config.set('symbols-view.preferCertainProviders', ['Competing Exclusive', 'symbol-provider-dummy']);
});
it('prefers the one with the highest score (providers listed earlier beating those listed later)', async () => {
registerProvider(DummyProvider, CompetingExclusiveProvider);
spyOn(CompetingExclusiveProvider, 'getSymbols').andCallThrough();
spyOn(DummyProvider, 'getSymbols').andCallThrough();
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
expect(choiceCount(symbolsView)).toBe(5);
expect(DummyProvider.getSymbols).not.toHaveBeenCalled();
expect(CompetingExclusiveProvider.getSymbols).toHaveBeenCalled();
});
});
});
describe('when no symbols are found', () => {
it('shows the list view with an error message', async () => {
registerProvider(EmptyProvider);
await activationPromise;
atom.commands.dispatch(getEditorView(), 'symbols-view:toggle-file-symbols');
await conditionPromise(() => getSymbolsView()?.selectListView.refs.emptyMessage);
symbolsView = getSymbolsView();
expect(document.body.contains(symbolsView.element));
expect(choiceCount(symbolsView)).toBe(0);
let refs = symbolsView.selectListView.refs;
expect(refs.emptyMessage).toBeVisible();
expect(refs.emptyMessage.textContent.length).toBeGreaterThan(0);
expect(refs.loadingMessage).not.toBeVisible();
});
});
describe("when symbols can't be generated for a file", () => {
it('does not show the list view', async () => {
registerProvider(UselessProvider);
await activationPromise;
expect(mainModule.broker.providers.length).toBe(1);
atom.commands.dispatch(getEditorView(), 'symbols-view:toggle-file-symbols');
await wait(1000);
symbolsView = atom.workspace.getModalPanels()[0].item;
// List view should not be visible, nor should it have any options.
expect(
symbolsView.element.querySelectorAll('li').length
).toBe(0);
expect(symbolsView.element).not.toBeVisible();
});
});
describe("when the user has enabled icons in the symbols list", () => {
beforeEach(() => {
atom.config.set('symbols-view.showIconsInSymbolsView', true);
});
it('shows icons in the symbols list', async () => {
registerProvider(DummyProvider);
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
expect(symbolsView.selectListView.refs.loadingMessage).toBeUndefined();
expect(document.body.contains(symbolsView.element)).toBe(true);
expect(symbolsView.element.querySelectorAll('li').length).toBe(5);
expect(symbolsView.element.querySelector('li:first-child .primary-line').classList.contains('icon-package')).toBe(true);
expect(symbolsView.element.querySelector('li:first-child .secondary-line').classList.contains('no-icon')).toBe(true);
expect(symbolsView.element.querySelector('li:nth-child(2) .primary-line').classList.contains('icon-key')).toBe(true);
expect(symbolsView.element.querySelector('li:nth-child(3) .primary-line').classList.contains('icon-gear')).toBe(true);
expect(symbolsView.element.querySelector('li:nth-child(4) .primary-line').classList.contains('icon-tag')).toBe(true);
// Simulate lack of icon on a random element.
expect(symbolsView.element.querySelector('li:nth-child(5) .primary-line').classList.contains('no-icon')).toBe(true);
});
});
});
describe('when going to declaration', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
});
describe('when no declaration is found', () => {
beforeEach(async () => {
registerProvider(EmptyProvider);
editor = atom.workspace.getActiveTextEditor();
});
it("doesn't move the cursor", async () => {
await activationPromise;
editor.setCursorBufferPosition([0, 2]);
atom.commands.dispatch(getEditorView(), 'symbols-view:toggle-project-symbols');
await wait(100);
expect(editor.getCursorBufferPosition()).toEqual([0, 2]);
});
});
describe('when there is a single matching declaration', () => {
beforeEach(async () => {
registerProvider(TaggedProvider);
await atom.workspace.open(directory.resolve('tagged.js'));
editor = atom.workspace.getActiveTextEditor();
});
it('moves the cursor to the declaration', async () => {
editor.setCursorBufferPosition([6, 24]);
spyOn(SymbolsView.prototype, 'moveToPosition').andCallThrough();
atom.commands.dispatch(getEditorView(), 'symbols-view:go-to-declaration');
await conditionPromise(() => {
return SymbolsView.prototype.moveToPosition.callCount === 1;
});
expect(editor.getCursorBufferPosition()).toEqual([2, 0]);
});
});
describe('when there is more than one matching declaration', () => {
beforeEach(async () => {
registerProvider(TaggedProvider);
TaggedProvider.mockResultCount = 2;
TaggedProvider.mockFileName = 'other-file.js';
await atom.workspace.open(directory.resolve('tagged.js'));
editor = atom.workspace.getActiveTextEditor();
await activationPromise;
});
afterEach(() => {
TaggedProvider.reset();
});
it('displays matches and opens the selected match', async () => {
editor.setCursorBufferPosition([8, 14]);
atom.commands.dispatch(getEditorView(), 'symbols-view:go-to-declaration');
symbolsView = getSymbolsView();
await conditionPromise(() => {
return symbolsView.element.querySelectorAll('li').length > 0;
});
expect(choiceCount(symbolsView)).toBe(2);
expect(symbolsView.element).toBeVisible();
spyOn(SymbolsView.prototype, 'moveToPosition').andCallThrough();
symbolsView.selectListView.confirmSelection();
await conditionPromise(() => {
return SymbolsView.prototype.moveToPosition.callCount === 1;
});
editor = atom.workspace.getActiveTextEditor();
expect(
atom.workspace.getActiveTextEditor().getPath()
).toBe(directory.resolve('other-file.js'));
expect(
atom.workspace.getActiveTextEditor().getCursorBufferPosition()
).toEqual([2, 0]);
});
});
});
describe('return from declaration', () => {
beforeEach(async () => {
registerProvider(TaggedProvider);
await atom.workspace.open(directory.resolve('tagged.js'));
await activationPromise;
editor = atom.workspace.getActiveTextEditor();
});
it("doesn't do anything when no go-tos have been triggered", async () => {
editor.setCursorBufferPosition([6, 0]);
atom.commands.dispatch(getEditorView(), 'symbols-view:return-from-declaration');
expect(editor.getCursorBufferPosition()).toEqual([6, 0]);
});
it('returns to the previous row and column', async () => {
editor.setCursorBufferPosition([6, 24]);
editor = atom.workspace.getActiveTextEditor();
spyOn(SymbolsView.prototype, 'moveToPosition').andCallThrough();
atom.commands.dispatch(getEditorView(), 'symbols-view:go-to-declaration');
await conditionPromise(() => {
return SymbolsView.prototype.moveToPosition.callCount === 1;
});
expect(editor.getCursorBufferPosition()).toEqual([2, 0]);
atom.commands.dispatch(getEditorView(), 'symbols-view:return-from-declaration');
await conditionPromise(() => SymbolsView.prototype.moveToPosition.callCount === 2);
expect(editor.getCursorBufferPosition()).toEqual([6, 24]);
});
});
describe('when toggling project symbols', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
});
it('displays all symbols', async () => {
registerProvider(DummyProvider);
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-project-symbols');
symbolsView = atom.workspace.getModalPanels()[0].item;
expect(symbolsView.selectListView.refs.loadingMessage).toBeUndefined();
expect(document.body.contains(symbolsView.element)).toBe(true);
expect(symbolsView.element.querySelectorAll('li').length).toBe(5);
let root = atom.project.getPaths()[1];
let resolved = directory.resolve('other-file.js');
let relative = `${path.basename(root)}${resolved.replace(root, '')}`;
expect(symbolsView.element.querySelector('li:first-child .primary-line')).toHaveText('Symbol on Row 1');
expect(symbolsView.element.querySelector('li:first-child .secondary-line')).toHaveText(`${relative}:1`);
expect(symbolsView.element.querySelector('li:last-child .primary-line')).toHaveText('Symbol on Row 13');
expect(symbolsView.element.querySelector('li:last-child .secondary-line')).toHaveText(`${relative}:13`);
});
it('asks for new symbols when the user starts typing', async () => {
registerProvider(ProgressiveProjectProvider);
spyOn(ProgressiveProjectProvider, 'getSymbols').andCallThrough();
await activationPromise;
atom.commands.dispatch(getEditorView(), 'symbols-view:toggle-project-symbols');
symbolsView = atom.workspace.getModalPanels()[0].item;
await wait(2000);
expect(symbolsView.element.querySelectorAll('li .primary-line').length).toBe(0);
expect(ProgressiveProjectProvider.getSymbols.callCount).toBe(1);
expect(symbolsView.selectListView.props.emptyMessage).toBe('Query must be at least 3 characters long.');
await symbolsView.updateView({ query: 'lor' });
await wait(2000);
expect(symbolsView.selectListView.props.emptyMessage).toBeNull();
expect(symbolsView.element.querySelectorAll('li .primary-line').length).toBe(1);
expect(symbolsView.element.querySelector('li:first-child .primary-line')).toHaveText('Lorem ipsum');
expect(ProgressiveProjectProvider.getSymbols.callCount).toBe(2);
});
describe('when there is only one project', () => {
beforeEach(() => {
atom.project.setPaths([directory.getPath()]);
});
it("does not include the root directory's name when displaying the symbol's filename", async () => {
registerProvider(TaggedProvider);
await atom.workspace.open(directory.resolve('tagged.js'));
await activationPromise;
expect(getWorkspaceView().querySelector('.symbols-view')).toBeNull();
await dispatchAndWaitForChoices('symbols-view:toggle-project-symbols');
symbolsView = getSymbolsView();
expect(choiceCount(symbolsView)).toBe(1);
expect(symbolsView.element.querySelector('li:first-child .primary-line')).toHaveText('callMeMaybe');
expect(symbolsView.element.querySelector('li:first-child .secondary-line')).toHaveText('tagged.js:3');
});
});
describe('when selecting a tag', () => {
describe("when the file doesn't exist", () => {
beforeEach(async () => fs.removeSync(directory.resolve('tagged.js')));
it("doesn't open the editor", async () => {
registerProvider(TaggedProvider);
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-project-symbols');
symbolsView = getSymbolsView();
spyOn(atom.workspace, 'open').andCallThrough();
symbolsView.element.querySelector('li:first-child').click();
await conditionPromise(() => symbolsView.selectListView.refs.errorMessage);
expect(atom.workspace.open).not.toHaveBeenCalled();
expect(
symbolsView.selectListView.refs.errorMessage.textContent.length
).toBeGreaterThan(0);
});
});
});
describe('match highlighting', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
editor = atom.workspace.getActiveTextEditor();
registerProvider(QuicksortProvider);
});
it('highlights an exact match', async () => {
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.refs.queryEditor.setText('quicksort');
await getOrScheduleUpdatePromise();
let resultView = symbolsView.element.querySelector('.selected');
let matches = resultView.querySelectorAll('.character-match');
expect(matches.length).toBe(1);
expect(matches[0].textContent).toBe('quicksort');
});
it('highlights a partial match', async () => {
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.refs.queryEditor.setText('quick');
await getOrScheduleUpdatePromise();
let resultView = symbolsView.element.querySelector('.selected');
let matches = resultView.querySelectorAll('.character-match');
expect(matches.length).toBe(1);
expect(matches[0].textContent).toBe('quick');
});
it('highlights multiple matches in the symbol name', async () => {
await activationPromise;
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.refs.queryEditor.setText('quicort');
await getOrScheduleUpdatePromise();
let resultView = symbolsView.element.querySelector('.selected');
let matches = resultView.querySelectorAll('.character-match');
expect(matches.length).toBe(2);
expect(matches[0].textContent).toBe('quic');
expect(matches[1].textContent).toBe('ort');
});
});
describe('when quickJumpToSymbol is true', () => {
beforeEach(async () => {
await atom.workspace.open(directory.resolve('sample.js'));
});
it('jumps to the selected function', async () => {
registerProvider(DummyProvider);
await activationPromise;
editor = atom.workspace.getActiveTextEditor();
expect(editor.getCursorBufferPosition()).toEqual([0, 0]);
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.selectNext();
expect(editor.getCursorBufferPosition()).toEqual([3, 4]);
});
// NOTE: If this test fails, could it have been because you opened the
// dev tools console? That seems to break it on a reliable basis. Not
// sure why yet.
it('restores previous editor state on cancel', async () => {
registerProvider(DummyProvider);
await activationPromise;
const bufferRanges = [{start: {row: 0, column: 0}, end: {row: 0, column: 3}}];
editor = atom.workspace.getActiveTextEditor();
editor.setSelectedBufferRanges(bufferRanges);
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.selectNext();
expect(editor.getCursorBufferPosition()).toEqual([3, 4]);
await symbolsView.cancel();
expect(editor.getSelectedBufferRanges()).toEqual(bufferRanges);
});
});
describe('when quickJumpToSymbol is false', () => {
beforeEach(async () => {
atom.config.set('symbols-view.quickJumpToFileSymbol', false);
await atom.workspace.open(directory.resolve('sample.js'));
});
it("won't jump to the selected function", async () => {
registerProvider(DummyProvider);
await activationPromise;
editor = atom.workspace.getActiveTextEditor();
expect(editor.getCursorBufferPosition()).toEqual([0, 0]);
await dispatchAndWaitForChoices('symbols-view:toggle-file-symbols');
symbolsView = getSymbolsView();
symbolsView.selectListView.selectNext();
expect(editor.getCursorBufferPosition()).toEqual([0, 0]);
});
});
});
describe('when migrating legacy setting', () => {
beforeEach(async () => {
atom.config.set(
'symbols-view.useEditorGrammarAsCtagsLanguage',
false,
{ source: atom.config.getUserConfigPath() }
);
spyOn(atom.notifications, 'addInfo');
});
afterEach(async () => {
atom.config.unset(
'symbols-view.useEditorGrammarAsCtagsLanguage',
{ source: atom.config.getUserConfigPath() }
);
});
it('migrates the setting as expected', () => {
expect(
atom.config.get('symbols-view.useEditorGrammarAsCtagsLanguage')
).toBe(false);
migrateOldConfigIfNeeded({ force: true });
expect(
atom.config.get(
'symbols-view.useEditorGrammarAsCtagsLanguage',
{ source: atom.config.getUserConfigPath() }
)
).toBeUndefined();
expect(
atom.config.get(
'symbol-provider-ctags.useEditorGrammarAsCtagsLanguage',
{ source: atom.config.getUserConfigPath() }
)
).toBe(false);
expect(atom.notifications.addInfo).not.toHaveBeenCalled();
});
describe('and a scope-specific override has been made for that setting', () => {
beforeEach(() => {
atom.config.set(
'symbols-view.useEditorGrammarAsCtagsLanguage',
true,
{
source: 'baz',
scopeSelector: ['.source.ts']
}
);
});
afterEach(() => {
atom.config.unset(
'symbols-view.useEditorGrammarAsCtagsLanguage',
{
source: 'baz',
scopeSelector: ['.source.ts']
}
);
});
it('alerts the user', () => {
migrateOldConfigIfNeeded({ force: true });
expect(atom.notifications.addInfo).toHaveBeenCalled();
expect(
atom.notifications.addInfo.mostRecentCall?.args[1].description
).toBe("The `symbols-view` package has migrated the setting `symbols-view.useEditorGrammarAsCtagsLanguage` to its new location inside the core package `symbol-provider-ctags`. If you have defined any scope-specific overrides for this setting, youll need to change those overrides manually.\n\nDetected overrides in the following locations:\n\n* `baz`");
});
});
});
});

View File

@ -0,0 +1,90 @@
@import "ui-variables";
@wedge: (360 / 16);
.badge-variant(@char, @num) {
.symbols-view-badge-variant-@{char} {
background-color: spin(@background-color-info, @wedge * @num) !important;
}
}
// Generate 16 different badge colors.
//
// We need colors that vary from one another, but they need to harmonize with
// the user's chosen UI theme. So we'll just use `spin` to move around the
// color wheel, starting with their UI theme's own badge background color, and
// varying the hue while keeping the saturation and brightness constant.
.badge-variant(e('0'), 0);
.badge-variant(e('1'), 1);
.badge-variant(e('2'), 2);
.badge-variant(e('3'), 3);
.badge-variant(e('4'), 4);
.badge-variant(e('5'), 5);
.badge-variant(e('6'), 6);
.badge-variant(e('7'), 7);
.badge-variant(e('8'), 8);
.badge-variant(e('9'), 9);
.badge-variant(e('a'), 10);
.badge-variant(e('b'), 11);
.badge-variant(e('c'), 12);
.badge-variant(e('d'), 13);
.badge-variant(e('e'), 14);
.badge-variant(e('f'), 15);
// Highlight matched text
.symbols-view .list-group .character-match {
color: @text-color-highlight;
font-weight: bold;
}
atom-panel.modal .symbols-view {
.primary-line {
display: flex !important;
flex-direction: row;
align-items: baseline;
.name {
text-overflow: ellipsis;
overflow: hidden;
white-space: pre;
margin-right: auto;
}
.badge-container {
display: block;
flex-grow: 0;
display: flex;
flex-direction: row;
.badge:not(:last-child) {
margin-right: 5px;
}
}
.badge-symbol-tag {
text-transform: capitalize;
}
}
.secondary-line {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
max-width: 100%;
.location {
display: block;
}
.context {
display: block;
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 0;
}
}
}