@adecore/editor-react 0.0.1 → 0.17.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/README.md +60 -0
- package/dist/AnchoredPopup.d.ts +12 -0
- package/dist/AnchoredPopup.js +27 -0
- package/dist/AttributionCard.d.ts +14 -0
- package/dist/AttributionCard.js +5 -0
- package/dist/BaselineStatus.d.ts +4 -0
- package/dist/BaselineStatus.js +13 -0
- package/dist/ChangeReview.d.ts +12 -0
- package/dist/ChangeReview.js +27 -0
- package/dist/CodeAuthorsCard.d.ts +6 -0
- package/dist/CodeAuthorsCard.js +22 -0
- package/dist/CompletionPopup.d.ts +8 -0
- package/dist/CompletionPopup.js +103 -0
- package/dist/EditorContextMenu.d.ts +8 -0
- package/dist/EditorContextMenu.js +23 -0
- package/dist/EditorView.d.ts +16 -0
- package/dist/EditorView.js +27 -0
- package/dist/FindReplace.d.ts +6 -0
- package/dist/FindReplace.js +44 -0
- package/dist/HoverCard.d.ts +11 -0
- package/dist/HoverCard.js +38 -0
- package/dist/HoverSections.d.ts +21 -0
- package/dist/HoverSections.js +82 -0
- package/dist/LanguagePopups.d.ts +6 -0
- package/dist/LanguagePopups.js +38 -0
- package/dist/Markdown.d.ts +3 -0
- package/dist/Markdown.js +7 -0
- package/dist/PathText.d.ts +4 -0
- package/dist/PathText.js +12 -0
- package/dist/PeekPanel.d.ts +6 -0
- package/dist/PeekPanel.js +111 -0
- package/dist/PickPopup.d.ts +8 -0
- package/dist/PickPopup.js +33 -0
- package/dist/RenameCard.d.ts +9 -0
- package/dist/RenameCard.js +108 -0
- package/dist/SignatureCard.d.ts +4 -0
- package/dist/SignatureCard.js +8 -0
- package/dist/SymbolPicker.d.ts +6 -0
- package/dist/SymbolPicker.js +108 -0
- package/dist/code-actions-model.d.ts +23 -0
- package/dist/code-actions-model.js +114 -0
- package/dist/code-actions.d.ts +30 -0
- package/dist/code-actions.js +326 -0
- package/dist/code-authors.d.ts +18 -0
- package/dist/code-authors.js +79 -0
- package/dist/code-vision-model.d.ts +21 -0
- package/dist/code-vision-model.js +183 -0
- package/dist/code-vision.d.ts +48 -0
- package/dist/code-vision.js +236 -0
- package/dist/completion-call.d.ts +24 -0
- package/dist/completion-call.js +110 -0
- package/dist/completion-model.d.ts +51 -0
- package/dist/completion-model.js +353 -0
- package/dist/completion.d.ts +43 -0
- package/dist/completion.js +463 -0
- package/dist/conflict-model.d.ts +15 -0
- package/dist/conflict-model.js +36 -0
- package/dist/context-menu.d.ts +10 -0
- package/dist/context-menu.js +55 -0
- package/dist/definition-link.d.ts +14 -0
- package/dist/definition-link.js +99 -0
- package/dist/diagnostics-model.d.ts +15 -0
- package/dist/diagnostics-model.js +83 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.js +95 -0
- package/dist/editor-language.d.ts +65 -0
- package/dist/editor-language.js +170 -0
- package/dist/editor-react.css +43 -0
- package/dist/find-query.d.ts +26 -0
- package/dist/find-query.js +51 -0
- package/dist/folding.d.ts +16 -0
- package/dist/folding.js +95 -0
- package/dist/highlight-layers.d.ts +10 -0
- package/dist/highlight-layers.js +30 -0
- package/dist/highlights.d.ts +16 -0
- package/dist/highlights.js +102 -0
- package/dist/history.d.ts +8 -0
- package/dist/history.js +46 -0
- package/dist/holder.d.ts +5 -0
- package/dist/holder.js +19 -0
- package/dist/host-types.d.ts +45 -0
- package/dist/host-types.js +1 -0
- package/dist/hover-content.d.ts +45 -0
- package/dist/hover-content.js +198 -0
- package/dist/hover.d.ts +32 -0
- package/dist/hover.js +260 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +28 -0
- package/dist/inlay-hints.d.ts +13 -0
- package/dist/inlay-hints.js +71 -0
- package/dist/language-ids.d.ts +3 -0
- package/dist/language-ids.js +49 -0
- package/dist/line-edits.d.ts +8 -0
- package/dist/line-edits.js +39 -0
- package/dist/locales/en.json +171 -0
- package/dist/locales/nl.json +171 -0
- package/dist/models.d.ts +20 -0
- package/dist/models.js +20 -0
- package/dist/navigation-history.d.ts +16 -0
- package/dist/navigation-history.js +53 -0
- package/dist/navigation.d.ts +23 -0
- package/dist/navigation.js +155 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +3 -0
- package/dist/peek-model.d.ts +28 -0
- package/dist/peek-model.js +104 -0
- package/dist/peek.d.ts +30 -0
- package/dist/peek.js +215 -0
- package/dist/pick.d.ts +34 -0
- package/dist/pick.js +166 -0
- package/dist/popup-placement.d.ts +23 -0
- package/dist/popup-placement.js +34 -0
- package/dist/popups.d.ts +133 -0
- package/dist/popups.js +14 -0
- package/dist/project-language.d.ts +36 -0
- package/dist/project-language.js +190 -0
- package/dist/project-problems.d.ts +24 -0
- package/dist/project-problems.js +63 -0
- package/dist/proposal-model.d.ts +36 -0
- package/dist/proposal-model.js +197 -0
- package/dist/recent-choices.d.ts +10 -0
- package/dist/recent-choices.js +39 -0
- package/dist/refresher.d.ts +13 -0
- package/dist/refresher.js +50 -0
- package/dist/relative-time.d.ts +1 -0
- package/dist/relative-time.js +9 -0
- package/dist/rename-model.d.ts +25 -0
- package/dist/rename-model.js +93 -0
- package/dist/rename.d.ts +21 -0
- package/dist/rename.js +257 -0
- package/dist/rendering-context.d.ts +6 -0
- package/dist/rendering-context.js +5 -0
- package/dist/rendering.d.ts +6 -0
- package/dist/rendering.js +5 -0
- package/dist/review-group.d.ts +16 -0
- package/dist/review-group.js +54 -0
- package/dist/row-host.d.ts +42 -0
- package/dist/row-host.js +120 -0
- package/dist/selection-ranges.d.ts +7 -0
- package/dist/selection-ranges.js +26 -0
- package/dist/semantic-model.d.ts +4 -0
- package/dist/semantic-model.js +76 -0
- package/dist/semantic-tokens.d.ts +6 -0
- package/dist/semantic-tokens.js +38 -0
- package/dist/shortcut-keys.d.ts +91 -0
- package/dist/shortcut-keys.js +12 -0
- package/dist/signature-model.d.ts +18 -0
- package/dist/signature-model.js +33 -0
- package/dist/signature.d.ts +22 -0
- package/dist/signature.js +150 -0
- package/dist/snippet-session.d.ts +17 -0
- package/dist/snippet-session.js +110 -0
- package/dist/snippet.d.ts +11 -0
- package/dist/snippet.js +134 -0
- package/dist/symbol-links.d.ts +6 -0
- package/dist/symbol-links.js +50 -0
- package/dist/symbol-picker-model.d.ts +44 -0
- package/dist/symbol-picker-model.js +146 -0
- package/dist/symbol-picker.d.ts +12 -0
- package/dist/symbol-picker.js +76 -0
- package/dist/symbols.d.ts +11 -0
- package/dist/symbols.js +95 -0
- package/dist/testing.d.ts +57 -0
- package/dist/testing.js +156 -0
- package/dist/timers.d.ts +14 -0
- package/dist/timers.js +34 -0
- package/dist/use-problem-counts.d.ts +3 -0
- package/dist/use-problem-counts.js +20 -0
- package/dist/view-state.d.ts +21 -0
- package/dist/view-state.js +63 -0
- package/dist/word-diff.d.ts +1 -0
- package/dist/word-diff.js +32 -0
- package/dist/workspace-edit.d.ts +21 -0
- package/dist/workspace-edit.js +147 -0
- package/package.json +74 -2
- package/src/AnchoredPopup.tsx +51 -0
- package/src/AttributionCard.tsx +36 -0
- package/src/BaselineStatus.tsx +20 -0
- package/src/ChangeReview.tsx +43 -0
- package/src/CodeAuthorsCard.tsx +81 -0
- package/src/CompletionPopup.tsx +196 -0
- package/src/EditorContextMenu.tsx +67 -0
- package/src/EditorView.tsx +47 -0
- package/src/FindReplace.tsx +89 -0
- package/src/HoverCard.tsx +121 -0
- package/src/HoverSections.tsx +146 -0
- package/src/LanguagePopups.tsx +65 -0
- package/src/Markdown.tsx +11 -0
- package/src/PathText.tsx +18 -0
- package/src/PeekPanel.tsx +222 -0
- package/src/PickPopup.tsx +102 -0
- package/src/RenameCard.tsx +219 -0
- package/src/SignatureCard.tsx +42 -0
- package/src/SymbolPicker.tsx +219 -0
- package/src/code-actions-model.ts +146 -0
- package/src/code-actions.ts +384 -0
- package/src/code-authors.ts +104 -0
- package/src/code-vision-model.ts +230 -0
- package/src/code-vision.ts +269 -0
- package/src/completion-call.ts +139 -0
- package/src/completion-model.ts +440 -0
- package/src/completion.ts +500 -0
- package/src/conflict-model.ts +56 -0
- package/src/context-menu.ts +63 -0
- package/src/definition-link.ts +106 -0
- package/src/diagnostics-model.ts +102 -0
- package/src/diagnostics.ts +117 -0
- package/src/editor-language.ts +189 -0
- package/src/editor-react.css +43 -0
- package/src/find-query.ts +74 -0
- package/src/folding.ts +110 -0
- package/src/highlight-layers.ts +37 -0
- package/src/highlights.ts +123 -0
- package/src/history.ts +56 -0
- package/src/holder.ts +19 -0
- package/src/host-types.ts +52 -0
- package/src/hover-content.ts +264 -0
- package/src/hover.ts +280 -0
- package/src/index.ts +63 -0
- package/src/inlay-hints.ts +85 -0
- package/src/language-ids.ts +54 -0
- package/src/line-edits.ts +48 -0
- package/src/locales/en.json +171 -0
- package/src/locales/nl.json +171 -0
- package/src/models.ts +157 -0
- package/src/navigation-history.ts +69 -0
- package/src/navigation.ts +168 -0
- package/src/paths.ts +3 -0
- package/src/peek-model.ts +147 -0
- package/src/peek.ts +247 -0
- package/src/pick.ts +197 -0
- package/src/popup-placement.ts +64 -0
- package/src/popups.ts +204 -0
- package/src/project-language.ts +223 -0
- package/src/project-problems.ts +87 -0
- package/src/proposal-model.ts +259 -0
- package/src/recent-choices.ts +48 -0
- package/src/refresher.ts +56 -0
- package/src/relative-time.ts +10 -0
- package/src/rename-model.ts +130 -0
- package/src/rename.ts +290 -0
- package/src/rendering-context.ts +12 -0
- package/src/rendering.tsx +6 -0
- package/src/review-group.ts +70 -0
- package/src/row-host.ts +158 -0
- package/src/selection-ranges.ts +32 -0
- package/src/semantic-model.ts +80 -0
- package/src/semantic-tokens.ts +46 -0
- package/src/shortcut-keys.ts +21 -0
- package/src/signature-model.ts +49 -0
- package/src/signature.ts +164 -0
- package/src/snippet-session.ts +122 -0
- package/src/snippet.ts +147 -0
- package/src/symbol-links.ts +72 -0
- package/src/symbol-picker-model.ts +198 -0
- package/src/symbol-picker.ts +85 -0
- package/src/symbols.ts +110 -0
- package/src/testing.ts +240 -0
- package/src/timers.ts +45 -0
- package/src/use-problem-counts.ts +25 -0
- package/src/view-state.ts +88 -0
- package/src/word-diff.ts +35 -0
- package/src/workspace-edit.ts +194 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
2
|
+
import { Refresher } from './refresher.ts';
|
|
3
|
+
import { decodeSemanticTokens } from './semantic-model.ts';
|
|
4
|
+
import { realTimers, type Timers } from './timers.ts';
|
|
5
|
+
|
|
6
|
+
const METHOD = 'textDocument/semanticTokens/full';
|
|
7
|
+
const PAUSE_MS = 200;
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Colors the editor draws from what the language servers know, over what the grammar made of the text:
|
|
11
|
+
* a class is a class and a call is a call where the grammar could only guess. It asks after a pause in
|
|
12
|
+
* typing and when a server says its offer changed, and keeps drawing the last answer, which follows its
|
|
13
|
+
* text through edits, in between, so the colors never blink out while the next one is on its way.
|
|
14
|
+
*/
|
|
15
|
+
export class SemanticTokensFeature {
|
|
16
|
+
private readonly refresher: Refresher;
|
|
17
|
+
|
|
18
|
+
constructor(language: EditorLanguage, timers: Timers = realTimers) {
|
|
19
|
+
const { editor, project, uri } = language;
|
|
20
|
+
this.refresher = new Refresher(
|
|
21
|
+
async (signal) => {
|
|
22
|
+
const { service } = project;
|
|
23
|
+
const legend = service.providerOptions(METHOD, uri)?.legend;
|
|
24
|
+
if (!service.supports(METHOD, uri) || legend === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const tokens = await service.semanticTokens(uri, { signal });
|
|
28
|
+
if (!signal.aborted) {
|
|
29
|
+
editor.setSemanticTokens(tokens === null ? null : decodeSemanticTokens(tokens, legend));
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
PAUSE_MS,
|
|
33
|
+
timers
|
|
34
|
+
);
|
|
35
|
+
const edits = editor.onTextChange(() => this.refresher.later());
|
|
36
|
+
const providers = language.onProvidersChanged(() => {
|
|
37
|
+
this.refresher.now();
|
|
38
|
+
});
|
|
39
|
+
language.onDispose(() => {
|
|
40
|
+
edits();
|
|
41
|
+
providers();
|
|
42
|
+
this.refresher.dispose();
|
|
43
|
+
editor.setSemanticTokens(null);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { chordOf, KEYMAP_IDS, parseChord, type Keymap } from '@adecore/editor/keymap';
|
|
2
|
+
import type { KeyChord } from '@adecore/editor';
|
|
3
|
+
import { matchesShortcut } from '@adecore/ui';
|
|
4
|
+
|
|
5
|
+
export interface LanguageShortcut extends KeyChord {
|
|
6
|
+
readonly apple: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shortcutsOf(keymap: Keymap, apple: boolean) {
|
|
10
|
+
const shortcuts = Object.fromEntries(
|
|
11
|
+
KEYMAP_IDS.map((id) => {
|
|
12
|
+
const text = chordOf(id, apple, keymap);
|
|
13
|
+
return [id, text === null ? null : { ...parseChord(text), apple }];
|
|
14
|
+
})
|
|
15
|
+
) as Record<keyof Keymap, LanguageShortcut | null>;
|
|
16
|
+
return { ...shortcuts, rename: shortcuts.renameSymbol };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isShortcut(target: LanguageShortcut | null, event: KeyboardEvent): boolean {
|
|
20
|
+
return target !== null && matchesShortcut(target, event, target.apple);
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SignatureHelp, SignatureInformation } from '@adecore/lsp';
|
|
2
|
+
import { documentationText } from './completion-model.ts';
|
|
3
|
+
|
|
4
|
+
/* The signature help of a call, ready to draw: the label with the active parameter marked, and what is said about it. */
|
|
5
|
+
export interface SignatureViewModel {
|
|
6
|
+
readonly label: string;
|
|
7
|
+
/* The active parameter's characters in the label, or null when the server named none. */
|
|
8
|
+
readonly active: { readonly start: number; readonly end: number } | null;
|
|
9
|
+
readonly parameterName: string;
|
|
10
|
+
readonly parameterDocumentation: string;
|
|
11
|
+
readonly documentation: string;
|
|
12
|
+
/* Zero-based, and how many overloads the server knows. */
|
|
13
|
+
readonly index: number;
|
|
14
|
+
readonly count: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Where a parameter's label stands in the signature's: given as a pair of offsets, or as the text to find in it. */
|
|
18
|
+
export function parameterSpan(signature: SignatureInformation, index: number): { start: number; end: number } | null {
|
|
19
|
+
const parameter = signature.parameters?.[index];
|
|
20
|
+
if (parameter === undefined) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (typeof parameter.label !== 'string') {
|
|
24
|
+
const [start, end] = parameter.label;
|
|
25
|
+
return start >= 0 && end <= signature.label.length && start < end ? { start, end } : null;
|
|
26
|
+
}
|
|
27
|
+
const start = signature.label.indexOf(parameter.label);
|
|
28
|
+
return start < 0 ? null : { start, end: start + parameter.label.length };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function signatureViewOf(help: SignatureHelp | null): SignatureViewModel | null {
|
|
32
|
+
if (help === null || help.signatures.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const index = Math.min(Math.max(0, help.activeSignature ?? 0), help.signatures.length - 1);
|
|
36
|
+
const signature = help.signatures[index]!;
|
|
37
|
+
const parameterIndex = signature.activeParameter ?? help.activeParameter ?? 0;
|
|
38
|
+
const active = parameterSpan(signature, parameterIndex);
|
|
39
|
+
const parameter = signature.parameters?.[parameterIndex];
|
|
40
|
+
return {
|
|
41
|
+
label: signature.label,
|
|
42
|
+
active,
|
|
43
|
+
parameterName: active === null ? '' : signature.label.slice(active.start, active.end).replace(/[?:].*$/s, ''),
|
|
44
|
+
parameterDocumentation: documentationText(parameter?.documentation),
|
|
45
|
+
documentation: documentationText(signature.documentation),
|
|
46
|
+
index,
|
|
47
|
+
count: help.signatures.length
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { SignatureHelp, SignatureHelpContext } from '@adecore/lsp';
|
|
2
|
+
import type { EditorPosition, EditorTextChange } from '@adecore/editor';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import { signatureViewOf } from './signature-model.ts';
|
|
5
|
+
import { isShortcut } from './shortcut-keys.ts';
|
|
6
|
+
import { realTimers, type Timers } from './timers.ts';
|
|
7
|
+
|
|
8
|
+
const METHOD = 'textDocument/signatureHelp';
|
|
9
|
+
const PAUSE_MS = 60;
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The parameters of the call the caret is in, while its arguments are typed. It opens on a trigger
|
|
13
|
+
* character of the server, such as `(` and `,`, and asks again as the caret moves or the text changes,
|
|
14
|
+
* so the active parameter follows; the server answering nothing is the caret leaving the call.
|
|
15
|
+
*/
|
|
16
|
+
export class SignatureFeature {
|
|
17
|
+
private readonly language: EditorLanguage;
|
|
18
|
+
private readonly timers: Timers;
|
|
19
|
+
private timer: unknown;
|
|
20
|
+
private request = 0;
|
|
21
|
+
private controller: AbortController | null = null;
|
|
22
|
+
private help: SignatureHelp | null = null;
|
|
23
|
+
private anchor: EditorPosition | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(language: EditorLanguage, timers: Timers = realTimers) {
|
|
26
|
+
this.language = language;
|
|
27
|
+
this.timers = timers;
|
|
28
|
+
const { editor } = language;
|
|
29
|
+
const offs = [
|
|
30
|
+
editor.onTextChange((change) => this.edited(change)),
|
|
31
|
+
editor.onCaret(() => this.retrigger()),
|
|
32
|
+
editor.onBlur(() => this.close()),
|
|
33
|
+
editor.onKeyDown((event) => {
|
|
34
|
+
if (event.key === 'Escape' && this.isOpen) {
|
|
35
|
+
this.close();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (isShortcut(this.language.shortcuts.parameterInfo, event)) {
|
|
39
|
+
this.invoke();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
|
|
43
|
+
return this.cycle(event.key === 'ArrowDown' ? 1 : -1);
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
})
|
|
47
|
+
];
|
|
48
|
+
language.onDispose(() => {
|
|
49
|
+
for (const off of offs) {
|
|
50
|
+
off();
|
|
51
|
+
}
|
|
52
|
+
this.close();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get isOpen(): boolean {
|
|
57
|
+
return this.language.popups.getState().signature !== null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Asks for the parameters of the call at the caret, as its key does. */
|
|
61
|
+
invoke(): void {
|
|
62
|
+
this.ask({ triggerKind: 1, isRetrigger: false });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Asks for the parameters as if `(` had just been typed, which is what a call opened by a completion is. */
|
|
66
|
+
trigger(): void {
|
|
67
|
+
this.ask({ triggerKind: 2, triggerCharacter: '(', isRetrigger: false });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Shows the next overload, or the one before, while the card is up and has more than one; false when it has not, which leaves the arrow to the caret. */
|
|
71
|
+
private cycle(step: 1 | -1): boolean {
|
|
72
|
+
const { help } = this;
|
|
73
|
+
if (help === null || help.signatures.length < 2 || !this.isOpen) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const count = help.signatures.length;
|
|
77
|
+
const next = { ...help, activeSignature: (Math.min(Math.max(0, help.activeSignature ?? 0), count - 1) + step + count) % count };
|
|
78
|
+
const model = signatureViewOf(next);
|
|
79
|
+
if (model === null || this.anchor === null) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
this.help = next;
|
|
83
|
+
this.language.popups.setState({ signature: { anchor: this.anchor, model } });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close(): void {
|
|
88
|
+
this.request++;
|
|
89
|
+
this.controller?.abort();
|
|
90
|
+
this.timers.clear(this.timer);
|
|
91
|
+
this.help = null;
|
|
92
|
+
this.anchor = null;
|
|
93
|
+
if (this.isOpen) {
|
|
94
|
+
this.language.popups.setState({ signature: null });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private options(): { triggers: readonly string[]; retriggers: readonly string[] } | null {
|
|
99
|
+
const { project, uri } = this.language;
|
|
100
|
+
const options = project.service.providerOptions(METHOD, uri);
|
|
101
|
+
return options === undefined ? null : { triggers: options.triggerCharacters ?? [], retriggers: options.retriggerCharacters ?? [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private edited(change: EditorTextChange): void {
|
|
105
|
+
const options = this.options();
|
|
106
|
+
if (change.source !== 'input' || options === null) {
|
|
107
|
+
this.close();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const text = change.changes.length === 1 ? change.changes[0]!.text : '';
|
|
111
|
+
const last = Array.from(text).at(-1) ?? '';
|
|
112
|
+
if (text.length === 1 && options.triggers.includes(last)) {
|
|
113
|
+
this.ask({ triggerKind: 2, triggerCharacter: last, isRetrigger: this.isOpen });
|
|
114
|
+
} else if (this.isOpen) {
|
|
115
|
+
this.ask({
|
|
116
|
+
triggerKind: text.length === 1 && options.retriggers.includes(last) ? 2 : 3,
|
|
117
|
+
...(options.retriggers.includes(last) ? { triggerCharacter: last } : {}),
|
|
118
|
+
isRetrigger: true
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private retrigger(): void {
|
|
124
|
+
if (this.isOpen) {
|
|
125
|
+
this.ask({ triggerKind: 3, isRetrigger: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private ask(context: Omit<SignatureHelpContext, 'activeSignatureHelp'>): void {
|
|
130
|
+
const { project, uri } = this.language;
|
|
131
|
+
if (!project.service.supports(METHOD, uri)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.timers.clear(this.timer);
|
|
135
|
+
const request = ++this.request;
|
|
136
|
+
const full: SignatureHelpContext = { ...context, ...(this.help === null ? {} : { activeSignatureHelp: this.help }) };
|
|
137
|
+
this.timer = this.timers.set(() => void this.fetch(full, request), PAUSE_MS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async fetch(context: SignatureHelpContext, request: number): Promise<void> {
|
|
141
|
+
const { editor, project, uri } = this.language;
|
|
142
|
+
const caret = editor.getCaret();
|
|
143
|
+
this.controller?.abort();
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
this.controller = controller;
|
|
146
|
+
let help: SignatureHelp | null;
|
|
147
|
+
try {
|
|
148
|
+
help = await project.service.signatureHelp(uri, caret, context, { signal: controller.signal });
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (request !== this.request) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const model = signatureViewOf(help);
|
|
156
|
+
if (model === null) {
|
|
157
|
+
this.close();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.help = help;
|
|
161
|
+
this.anchor ??= caret;
|
|
162
|
+
this.language.popups.setState({ signature: { anchor: this.anchor, model } });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { EditorPosition, EditorRange, EditorTextChange } from '@adecore/editor';
|
|
2
|
+
import { comparePositions, rangeHolds, shiftRange } from './diagnostics-model.ts';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import type { SnippetStop } from './snippet.ts';
|
|
5
|
+
|
|
6
|
+
/* Where an offset of inserted text lands when the text is put in at `start`. */
|
|
7
|
+
export function positionInInsertion(start: EditorPosition, text: string, offset: number): EditorPosition {
|
|
8
|
+
const lines = text.slice(0, offset).split(/\r\n|\r|\n/);
|
|
9
|
+
return lines.length === 1
|
|
10
|
+
? { line: start.line, character: start.character + offset }
|
|
11
|
+
: { line: start.line + lines.length - 1, character: lines[lines.length - 1]!.length };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The tab stops of a snippet that was just inserted. The caret goes to the first one, with its default text
|
|
16
|
+
* selected; Tab goes on to the next and Shift+Tab back, and the last stop (`$0`, or the end of the text)
|
|
17
|
+
* ends it. Escape, a caret that leaves the snippet and any change that is not typing end it too. Typing in
|
|
18
|
+
* a stop does not touch the others: they follow the text, but a mirror of a stop is not edited along.
|
|
19
|
+
*/
|
|
20
|
+
export class SnippetFeature {
|
|
21
|
+
private readonly language: EditorLanguage;
|
|
22
|
+
private stops: EditorRange[] = [];
|
|
23
|
+
private current = 0;
|
|
24
|
+
|
|
25
|
+
constructor(language: EditorLanguage) {
|
|
26
|
+
this.language = language;
|
|
27
|
+
const { editor } = language;
|
|
28
|
+
const offs = [
|
|
29
|
+
editor.onKeyDown((event) => this.key(event)),
|
|
30
|
+
editor.onTextChange((change) => this.edited(change)),
|
|
31
|
+
editor.onCaret((position) => this.moved(position))
|
|
32
|
+
];
|
|
33
|
+
language.onDispose(() => {
|
|
34
|
+
for (const off of offs) {
|
|
35
|
+
off();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get isActive(): boolean {
|
|
41
|
+
return this.stops.length > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Puts the caret where the snippet says, and keeps the stops to go through when there is more than the last one. */
|
|
45
|
+
begin(start: EditorPosition, text: string, stops: readonly SnippetStop[]): void {
|
|
46
|
+
this.end();
|
|
47
|
+
if (stops.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const ranges = stops.map((stop) => ({ start: positionInInsertion(start, text, stop.start), end: positionInInsertion(start, text, stop.end) }));
|
|
51
|
+
if (ranges.length === 1) {
|
|
52
|
+
this.language.editor.setCaret(ranges[0]!.start);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.stops = ranges;
|
|
56
|
+
this.visit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
end(): void {
|
|
60
|
+
this.stops = [];
|
|
61
|
+
this.current = 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private visit(index: number): void {
|
|
65
|
+
const { editor } = this.language;
|
|
66
|
+
const target = this.stops[index]!;
|
|
67
|
+
this.current = index;
|
|
68
|
+
if (comparePositions(target.start, target.end) === 0) {
|
|
69
|
+
editor.setCaret(target.start);
|
|
70
|
+
} else {
|
|
71
|
+
editor.setSelection(target);
|
|
72
|
+
}
|
|
73
|
+
if (index === this.stops.length - 1) {
|
|
74
|
+
this.end();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private key(event: KeyboardEvent): boolean {
|
|
79
|
+
if (!this.isActive || event.ctrlKey || event.metaKey || event.altKey) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (event.key === 'Tab') {
|
|
83
|
+
this.visit(event.shiftKey ? Math.max(0, this.current - 1) : this.current + 1);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (event.key === 'Escape') {
|
|
87
|
+
this.end();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private edited(change: EditorTextChange): void {
|
|
94
|
+
if (!this.isActive) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (change.source !== 'input') {
|
|
98
|
+
this.end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.stops = this.stops.map((stop) => shiftRange(stop, change.changes));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* A caret outside the stretch the stops span is a person moving on. */
|
|
105
|
+
private moved(position: EditorPosition): void {
|
|
106
|
+
if (!this.isActive) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const hull = { start: this.stops[0]!.start, end: this.stops[this.stops.length - 1]!.end };
|
|
110
|
+
for (const stop of this.stops) {
|
|
111
|
+
if (comparePositions(stop.start, hull.start) < 0) {
|
|
112
|
+
hull.start = stop.start;
|
|
113
|
+
}
|
|
114
|
+
if (comparePositions(stop.end, hull.end) > 0) {
|
|
115
|
+
hull.end = stop.end;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!rangeHolds(hull, position)) {
|
|
119
|
+
this.end();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/snippet.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* A tab stop of a snippet: its index, and where it stands in the text the snippet inserts. */
|
|
2
|
+
export interface SnippetStop {
|
|
3
|
+
readonly index: number;
|
|
4
|
+
/* UTF-16 offsets into the inserted text; equal for a stop that holds no text. */
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParsedSnippet {
|
|
10
|
+
readonly text: string;
|
|
11
|
+
/* In text order, a stop of the same index again being a mirror, which is not edited along. */
|
|
12
|
+
readonly stops: readonly SnippetStop[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NAME = '[A-Za-z_]\\w*';
|
|
16
|
+
const TABSTOP = new RegExp(`^\\$(\\d+)|^\\$\\{(\\d+)\\}`);
|
|
17
|
+
const PLACEHOLDER = /^\$\{(\d+):/;
|
|
18
|
+
const CHOICE = /^\$\{(\d+)\|/;
|
|
19
|
+
const DEFAULT_VARIABLE = new RegExp(`^\\$\\{${NAME}:`);
|
|
20
|
+
const TRANSFORMED_VARIABLE = new RegExp(`^\\$\\{${NAME}/`);
|
|
21
|
+
const BARE_VARIABLE = new RegExp(`^\\$${NAME}|^\\$\\{${NAME}\\}`);
|
|
22
|
+
|
|
23
|
+
class SnippetParser {
|
|
24
|
+
text = '';
|
|
25
|
+
readonly stops: SnippetStop[] = [];
|
|
26
|
+
private at = 0;
|
|
27
|
+
private readonly source: string;
|
|
28
|
+
|
|
29
|
+
constructor(source: string) {
|
|
30
|
+
this.source = source;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
parse(): ParsedSnippet {
|
|
34
|
+
this.sequence(false);
|
|
35
|
+
return { text: this.text, stops: this.stops };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Text up to the `}` that closes the placeholder this is in, which is left for the caller. */
|
|
39
|
+
private sequence(nested: boolean): void {
|
|
40
|
+
while (this.at < this.source.length) {
|
|
41
|
+
const character = this.source[this.at]!;
|
|
42
|
+
if (character === '}' && nested) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (character === '\\' && /[\\$}]/.test(this.source[this.at + 1] ?? '')) {
|
|
46
|
+
this.text += this.source[this.at + 1];
|
|
47
|
+
this.at += 2;
|
|
48
|
+
} else if (character === '$') {
|
|
49
|
+
this.dollar();
|
|
50
|
+
} else {
|
|
51
|
+
this.text += character;
|
|
52
|
+
this.at++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private dollar(): void {
|
|
58
|
+
const rest = this.source.slice(this.at);
|
|
59
|
+
const tabstop = TABSTOP.exec(rest);
|
|
60
|
+
const placeholder = PLACEHOLDER.exec(rest);
|
|
61
|
+
const choice = CHOICE.exec(rest);
|
|
62
|
+
if (tabstop !== null) {
|
|
63
|
+
this.at += tabstop[0].length;
|
|
64
|
+
this.stops.push({ index: Number(tabstop[1] ?? tabstop[2]), start: this.text.length, end: this.text.length });
|
|
65
|
+
} else if (placeholder !== null) {
|
|
66
|
+
this.at += placeholder[0].length;
|
|
67
|
+
const start = this.text.length;
|
|
68
|
+
this.sequence(true);
|
|
69
|
+
this.closeBrace();
|
|
70
|
+
this.stops.push({ index: Number(placeholder[1]), start, end: this.text.length });
|
|
71
|
+
} else if (choice !== null) {
|
|
72
|
+
this.at += choice[0].length;
|
|
73
|
+
const start = this.text.length;
|
|
74
|
+
this.firstChoice();
|
|
75
|
+
this.stops.push({ index: Number(choice[1]), start, end: this.text.length });
|
|
76
|
+
} else if (DEFAULT_VARIABLE.test(rest)) {
|
|
77
|
+
this.at += rest.indexOf(':') + 1;
|
|
78
|
+
this.sequence(true);
|
|
79
|
+
this.closeBrace();
|
|
80
|
+
} else if (TRANSFORMED_VARIABLE.test(rest)) {
|
|
81
|
+
this.skipBraces();
|
|
82
|
+
} else if (BARE_VARIABLE.test(rest)) {
|
|
83
|
+
this.at += BARE_VARIABLE.exec(rest)![0].length;
|
|
84
|
+
} else {
|
|
85
|
+
this.text += '$';
|
|
86
|
+
this.at++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private closeBrace(): void {
|
|
91
|
+
if (this.source[this.at] === '}') {
|
|
92
|
+
this.at++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* The first of `a,b,c|}`, and the rest dropped. */
|
|
97
|
+
private firstChoice(): void {
|
|
98
|
+
let first = true;
|
|
99
|
+
while (this.at < this.source.length && !(this.source[this.at] === '|' && this.source[this.at + 1] === '}')) {
|
|
100
|
+
const character = this.source[this.at]!;
|
|
101
|
+
if (character === '\\' && /[\\,|]/.test(this.source[this.at + 1] ?? '')) {
|
|
102
|
+
this.at++;
|
|
103
|
+
if (first) {
|
|
104
|
+
this.text += this.source[this.at];
|
|
105
|
+
}
|
|
106
|
+
} else if (character === ',') {
|
|
107
|
+
first = false;
|
|
108
|
+
} else if (first) {
|
|
109
|
+
this.text += character;
|
|
110
|
+
}
|
|
111
|
+
this.at++;
|
|
112
|
+
}
|
|
113
|
+
this.at += 2;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Past the `}` that closes a `${name/regex/format/}` variable, which inserts nothing here. */
|
|
117
|
+
private skipBraces(): void {
|
|
118
|
+
let depth = 0;
|
|
119
|
+
while (this.at < this.source.length) {
|
|
120
|
+
const character = this.source[this.at]!;
|
|
121
|
+
this.at += character === '\\' ? 2 : 1;
|
|
122
|
+
if (character === '{') {
|
|
123
|
+
depth++;
|
|
124
|
+
} else if (character === '}' && --depth === 0) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseSnippet(snippet: string): ParsedSnippet {
|
|
132
|
+
return new SnippetParser(snippet).parse();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* The stops in the order Tab visits them: 1, 2 and on, one per index, then the final stop `$0`, which the end of the text stands in for. */
|
|
136
|
+
export function tabOrder(parsed: ParsedSnippet): SnippetStop[] {
|
|
137
|
+
const first = new Map<number, SnippetStop>();
|
|
138
|
+
for (const stop of parsed.stops) {
|
|
139
|
+
const known = first.get(stop.index);
|
|
140
|
+
if (known === undefined || stop.start < known.start) {
|
|
141
|
+
first.set(stop.index, stop);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const visited = [...first.values()].filter((stop) => stop.index > 0).sort((left, right) => left.index - right.index);
|
|
145
|
+
const final = first.get(0) ?? { index: 0, start: parsed.text.length, end: parsed.text.length };
|
|
146
|
+
return [...visited, final];
|
|
147
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Location, SymbolInformation, WorkspaceSymbol, WorkspaceSymbolResult } from '@adecore/lsp';
|
|
2
|
+
|
|
3
|
+
/* The classes a name in a signature has: a pointer and an underline on hover, and the accent only then, so the signature stays calm. */
|
|
4
|
+
export const SYMBOL_LINK_CLASS = 'cursor-pointer hover:text-accent hover:underline';
|
|
5
|
+
|
|
6
|
+
/* A name that reads as a type: it starts with a capital and has a lowercase letter, which leaves `DEFAULT_WEIGHTS` and `T` alone. */
|
|
7
|
+
const TYPE_NAME = /^[A-Z][A-Za-z0-9_]*$/;
|
|
8
|
+
|
|
9
|
+
export function isTypeName(name: string): boolean {
|
|
10
|
+
return TYPE_NAME.test(name) && /[a-z]/.test(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* Marks the type-like names of a highlighted snippet as links. Shiki writes each token as a span of its own,
|
|
15
|
+
* so a name that is the whole text of a token is one; a string or a comment is never that.
|
|
16
|
+
*/
|
|
17
|
+
export function linkTypeNames(html: string): string {
|
|
18
|
+
return html.replace(/>([A-Z][A-Za-z0-9_]*)</g, (match, name: string) =>
|
|
19
|
+
isTypeName(name) ? `><span data-symbol="${name}" class="${SYMBOL_LINK_CLASS}">${name}</span><` : match
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DECLARES = /\b(?:class|interface|enum|trait|type|namespace|struct|function|const|let|var)\s+&?([A-Za-z_$][\w$]*)/;
|
|
24
|
+
|
|
25
|
+
/* The name a signature declares, such as `IssueTicketsMessage` in `final class IssueTicketsMessage implements MessageInterface`; null for one that declares nothing, such as a method of a class. */
|
|
26
|
+
export function declaredNameOf(code: string): string | null {
|
|
27
|
+
return DECLARES.exec(code)?.[1] ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* What a workspace symbol is that a type name can mean. */
|
|
31
|
+
const TYPE_KINDS = new Set([2, 3, 5, 10, 11, 23, 26]);
|
|
32
|
+
|
|
33
|
+
interface Found {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly kind: number;
|
|
36
|
+
readonly containerName?: string;
|
|
37
|
+
readonly location: Location | { uri: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* The places a type name leads to among the symbols a server found for it. Only symbols of exactly that name
|
|
42
|
+
* count, those that are types ahead of the rest, and when several are left the ones whose container (a
|
|
43
|
+
* namespace) the current file names, which is how a file says which `Message` it means.
|
|
44
|
+
*/
|
|
45
|
+
export function placesOfName(result: WorkspaceSymbolResult, name: string, fileText: string): Location[] {
|
|
46
|
+
const found: Found[] = ((result ?? []) as (SymbolInformation | WorkspaceSymbol)[]).filter((symbol) => symbol.name === name);
|
|
47
|
+
const types = found.filter((symbol) => TYPE_KINDS.has(symbol.kind));
|
|
48
|
+
let candidates = types.length > 0 ? types : found;
|
|
49
|
+
if (candidates.length > 1) {
|
|
50
|
+
const named = candidates.filter(
|
|
51
|
+
(symbol) => symbol.containerName !== undefined && symbol.containerName !== '' && fileText.includes(symbol.containerName)
|
|
52
|
+
);
|
|
53
|
+
if (named.length > 0) {
|
|
54
|
+
candidates = named;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
return candidates
|
|
59
|
+
.map((symbol): Location =>
|
|
60
|
+
'range' in symbol.location
|
|
61
|
+
? symbol.location
|
|
62
|
+
: { uri: symbol.location.uri, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }
|
|
63
|
+
)
|
|
64
|
+
.filter((location) => {
|
|
65
|
+
const key = `${location.uri}\0${location.range.start.line}`;
|
|
66
|
+
if (seen.has(key)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
seen.add(key);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
}
|