mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-08-14 13:15:14 +03:00
feat(ecode): add VS Code-compatible user snippets
Add user-defined snippet support to the autocomplete plugin, including language-specific, global, .vscode, and .ecode project snippets. Support JSONC parsing, scopes, hot reload, source-aware ranking, exact prefix replacement, multi-cursor insertion, contextual indentation, variables, transforms, choices, linked placeholders, and tab navigation. Recognize .code-snippets files as JSON and preserve existing LSP snippet completion behavior. Refs SpartanJ/ecode#111
This commit is contained in:
882
.agent/plans/ecode_user_defined_snippets_plan.md
Normal file
882
.agent/plans/ecode_user_defined_snippets_plan.md
Normal file
@@ -0,0 +1,882 @@
|
||||
# ecode User-Defined Snippets Implementation Plan
|
||||
|
||||
Status: **Phase 1 implemented and manually validated. Phase 2 not started. Phase 3 optional.**
|
||||
|
||||
Last status review: 2026-07-29.
|
||||
|
||||
Related issue: [SpartanJ/ecode#111](https://github.com/SpartanJ/ecode/issues/111)
|
||||
|
||||
## 0. Current status and remaining decisions
|
||||
|
||||
The core feature is working and has been tested interactively with project-local C/C++ snippets.
|
||||
The implementation currently supports loading, matching, inserting, navigating, and hot-reloading
|
||||
VS Code-compatible user and project snippets. It also preserves the existing LSP snippet behavior.
|
||||
|
||||
Completed:
|
||||
|
||||
- Phase 1 definition model, JSON/JSONC parser, immutable store, indexes, and focused tests;
|
||||
- user snippets from `<ecode-config>/snippets`;
|
||||
- project snippets from `.vscode/*.code-snippets` and `.ecode/*.code-snippets`;
|
||||
- asynchronous initial loading and incremental create/modify/move/delete reloads;
|
||||
- workspace switching, generation-based stale-job rejection, and shutdown synchronization;
|
||||
- last-known-good definitions after an invalid edit and content-hash suppression of unchanged
|
||||
valid files;
|
||||
- global and language scopes, string/array prefixes, string/array bodies, and descriptions;
|
||||
- autocomplete integration before and after asynchronous LSP completion responses;
|
||||
- same-prefix snippet identity, punctuation-aware replacement, empty/short-prefix matching, and
|
||||
source precedence only between equally matching user snippets;
|
||||
- contextual multiline indentation, variables, linked placeholders, choices, tab navigation, and
|
||||
multi-cursor insertion through the shared snippet runtime;
|
||||
- allocation-conscious filesystem filtering and cached watched-directory paths;
|
||||
- `.code-snippets` recognition as JSON by the syntax-definition system;
|
||||
- a reusable JSONC trailing-comma helper in `jsonhelper.hpp` / `jsonhelper.cpp`;
|
||||
- a project-local `.ecode/c-cpp.code-snippets` manual fixture.
|
||||
|
||||
Phase 1 hardening that remains useful but is not blocking current use:
|
||||
|
||||
- broaden loader/index tests for UTF-8, punctuation prefixes, empty-pattern ordering, duplicate
|
||||
names across files, and unsupported fields;
|
||||
- add automated editor-level coverage for replacement ranges, indentation, multi-cursor
|
||||
insertion, and filesystem reloads where practical;
|
||||
- debounce/coalesce repeated invalid-file save events so the same malformed content is not parsed
|
||||
and logged repeatedly;
|
||||
- keep the new user-facing `docs/snippets.md` guide current as Phase 2 capabilities are added;
|
||||
- run the full manual matrix from Section 17.4 against more real-world snippet collections.
|
||||
|
||||
Everything in Phase 2 is now a product choice rather than a prerequisite for useful snippet
|
||||
support. The most independently useful candidates are:
|
||||
|
||||
1. an **Insert Snippet** searchable command;
|
||||
2. a **Configure Snippets** command that creates/opens the current language file;
|
||||
3. optional exact-prefix Tab expansion when no popup or active snippet session exists;
|
||||
4. `include` / `exclude` file-pattern scopes;
|
||||
5. additional workspace, clipboard, cursor, date/time, random, UUID, and comment variables.
|
||||
|
||||
Phase 3 remains explicitly optional and should be driven only by observed compatibility needs.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Add user-defined snippets to ecode using the VS Code JSON/JSONC snippet file format while reusing
|
||||
the snippet parser, insertion, multi-cursor, placeholder, choice, and tab-navigation behavior that
|
||||
already exists in the autocomplete plugin.
|
||||
|
||||
The implementation should provide:
|
||||
|
||||
- user-level, language-specific snippets;
|
||||
- user-level global and multi-language snippets;
|
||||
- project snippets from existing `.vscode/*.code-snippets` files;
|
||||
- native project snippets from `.ecode/*.code-snippets` files;
|
||||
- snippet discovery through the normal autocomplete popup;
|
||||
- explicit prefix replacement, including prefixes that are not ordinary programming-language
|
||||
words;
|
||||
- hot reload when snippet files are created, modified, moved, or deleted;
|
||||
- predictable behavior with multiple cursors;
|
||||
- loading and matching that do not block the UI thread.
|
||||
|
||||
This plan intentionally distinguishes **file-format compatibility** from **complete VS Code runtime
|
||||
parity**. Phase 1 and Phase 2 are the intended implementation. Phase 3 is optional and should only
|
||||
be attempted when concrete snippet packs or users need the additional behavior.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
The initial implementation must not:
|
||||
|
||||
- execute TextMate interpolated shell commands or backtick expressions;
|
||||
- load Sublime `.sublime-snippet` XML files;
|
||||
- load TextMate `.tmSnippet` plist/XML files;
|
||||
- introduce a second plugin that competes with the autocomplete plugin for completion UI or key
|
||||
handling;
|
||||
- automatically discover every possible VS Code, VSCodium, portable, or profile-specific user
|
||||
configuration directory;
|
||||
- claim complete VS Code semantic compatibility;
|
||||
- implement automatic expansion on Space;
|
||||
- require file-template snippets, placeholder transforms, or every VS Code variable before the
|
||||
first useful release.
|
||||
|
||||
Unsupported recognized properties should be ignored safely and, where useful, reported once in
|
||||
the log. Unknown properties should remain forward-compatible and must not invalidate an otherwise
|
||||
valid snippet.
|
||||
|
||||
## 3. Architectural decision
|
||||
|
||||
Keep user-defined snippets inside `AutoCompletePlugin`, but separate storage concerns into a new
|
||||
class next to the existing parser:
|
||||
|
||||
```text
|
||||
AutoCompletePlugin
|
||||
|- UserSnippetStore file discovery, JSONC parsing, scope/index management, reload
|
||||
|- SnippetParser snippet body expansion into text and tab-stop metadata
|
||||
`- snippet session editor insertion, selections, mirrors, choices, navigation
|
||||
```
|
||||
|
||||
Suggested files:
|
||||
|
||||
- `src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp`
|
||||
- `src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp`
|
||||
- `src/tests/unit_tests/usersnippetstore_tests.cpp`
|
||||
|
||||
Do not make `UserSnippetStore` a plugin. `AutoCompletePlugin` should remain the owner of:
|
||||
|
||||
- the completion popup;
|
||||
- completion-list merging and ranking;
|
||||
- editor commands and keybindings;
|
||||
- snippet insertion and navigation sessions;
|
||||
- rendering and choice UI.
|
||||
|
||||
`UserSnippetStore` should not know about `UICodeEditor`, draw UI, intercept keys, edit documents,
|
||||
or send LSP requests. This boundary allows later extraction if ecode eventually introduces a
|
||||
general completion-provider interface, without paying that architectural cost now.
|
||||
|
||||
## 4. Current implementation premises
|
||||
|
||||
The existing implementation already provides the expensive editor-side mechanics:
|
||||
|
||||
- `SnippetParser` parses tab stops, placeholders, nested placeholders, linked occurrences,
|
||||
choices, variables, and variable transforms.
|
||||
- `AutoCompletePlugin::pickSuggestion()` parses snippet text separately for every selection and
|
||||
inserts it at every cursor.
|
||||
- `SnippetSession` tracks tab-stop groups across one or more snippet insertions.
|
||||
- `SnippetDocumentClient` translates snippet ranges as the document changes.
|
||||
- snippet choices use the existing autocomplete popup.
|
||||
- LSP completion requests remain on the worker path through `runUpdateSuggestions()`.
|
||||
|
||||
The following existing assumptions must be corrected as part of user-snippet integration:
|
||||
|
||||
1. `Suggestion` uses `text` for display, matching, and equality. `operator==` compares only
|
||||
`text`, so two user snippets with the same prefix would collapse into one.
|
||||
2. `fuzzyMatchSymbols()` accepts every suggestion whose kind is `Snippet` without testing the
|
||||
current pattern. That works for server-filtered LSP results, but it would expose every entry in
|
||||
a large user snippet pack on every query.
|
||||
3. Normal suggestion insertion can fall back to `delete-to-previous-word`. User snippet prefixes
|
||||
may contain punctuation and cannot rely on the document's word definition.
|
||||
4. Local symbols and LSP completions are composed in more than one code path. User snippets must
|
||||
be included consistently before and after an asynchronous LSP response.
|
||||
5. The base plugin filesystem handler watches only `autocomplete.json` semantically and reloads
|
||||
the complete plugin. Snippet directories need their own incremental event handling.
|
||||
|
||||
## 5. Supported format
|
||||
|
||||
### 5.1 File types
|
||||
|
||||
Support:
|
||||
|
||||
- `<language-id>.json`: all definitions in the file apply to that language;
|
||||
- `*.code-snippets`: definitions are global unless their `scope` restricts them.
|
||||
|
||||
Parse JSON with comments enabled, consistent with the existing `json::parse(..., true)` usage in
|
||||
ecode.
|
||||
|
||||
### 5.2 Initial definition fields
|
||||
|
||||
Support these VS Code fields in Phase 1:
|
||||
|
||||
- the root property name as the snippet's name;
|
||||
- `prefix` as a string or array of strings;
|
||||
- `body` as a string or array of strings;
|
||||
- `description` as an optional string;
|
||||
- `scope` as an optional comma-separated string in `.code-snippets` files.
|
||||
|
||||
Normalize a body array by joining its entries with `\n`. Do not parse the body with
|
||||
`SnippetParser` while loading. Parsing depends on the selection, document, cursor index, workspace,
|
||||
and other runtime variables, so it must remain deferred until insertion.
|
||||
|
||||
Reject only the invalid definition, not the complete file, when:
|
||||
|
||||
- `prefix` is absent, empty, or neither a string nor an array of strings;
|
||||
- `body` is absent or neither a string nor an array of strings;
|
||||
- a prefix or body array contains a non-string value;
|
||||
- `scope` is present with an unsupported type.
|
||||
|
||||
Log the file path and snippet name for skipped definitions. Do not log full snippet bodies, since
|
||||
user snippets can contain private data.
|
||||
|
||||
### 5.3 Recognized but deferred fields
|
||||
|
||||
Parse or recognize these fields so their presence does not produce confusing behavior:
|
||||
|
||||
- `isFileTemplate`;
|
||||
- `include`;
|
||||
- `exclude`.
|
||||
|
||||
`include` and `exclude` are planned for Phase 2. `isFileTemplate` belongs to optional Phase 3.
|
||||
Until supported, do not expose `isFileTemplate` snippets through a file-template command that does
|
||||
not exist. They may still appear as ordinary insertion snippets unless testing shows VS Code hides
|
||||
them from normal completion; document the chosen behavior.
|
||||
|
||||
### 5.4 Body compatibility boundary
|
||||
|
||||
Phase 1 supports whatever the existing `SnippetParser` handles correctly. Explicitly document
|
||||
these known limitations:
|
||||
|
||||
- placeholder transforms such as `${1/(.*)/${1:/upcase}/}` are not supported;
|
||||
- transform case modifiers are initially limited to the modifiers already implemented;
|
||||
- ecode regular expressions are used instead of JavaScript regular expressions, so obscure regex
|
||||
constructs may differ;
|
||||
- not every VS Code variable is initially defined;
|
||||
- interpolated shell execution is deliberately unsupported.
|
||||
|
||||
Unknown variables must retain the existing parser behavior: an unknown bare variable becomes an
|
||||
editable synthetic placeholder, while a variable with a fallback uses its fallback.
|
||||
|
||||
## 6. Source discovery and precedence
|
||||
|
||||
### 6.1 Default locations
|
||||
|
||||
Load snippets from:
|
||||
|
||||
1. `<ecode-config>/snippets/<language-id>.json`
|
||||
2. `<ecode-config>/snippets/*.code-snippets`
|
||||
3. `<workspace>/.vscode/*.code-snippets`
|
||||
4. `<workspace>/.ecode/*.code-snippets`
|
||||
|
||||
The exact config root must come from the existing application/plugin configuration APIs. Do not
|
||||
reconstruct platform-specific config paths inside `UserSnippetStore`.
|
||||
|
||||
Only files directly inside these directories are required initially. Recursive snippet-directory
|
||||
scans are unnecessary unless real snippet packs require them.
|
||||
|
||||
### 6.2 Source ranking
|
||||
|
||||
Use source priority for tie-breaking, not destructive prefix deduplication:
|
||||
|
||||
1. native project `.ecode` snippets;
|
||||
2. compatible project `.vscode` snippets;
|
||||
3. user snippets.
|
||||
|
||||
Multiple snippets may intentionally share a prefix and all must remain selectable. The popup
|
||||
should use name, description, and optionally source detail to distinguish them. A source reload
|
||||
replaces definitions originating from that source file, but snippets from other files must not be
|
||||
removed merely because their names or prefixes match.
|
||||
|
||||
Use canonical normalized paths plus the root snippet property name as the stable source identity.
|
||||
Do not use prefix alone as identity.
|
||||
|
||||
### 6.3 Language identifiers
|
||||
|
||||
Treat `SyntaxDefinition::getLSPName()` as the canonical language identifier because it is generally
|
||||
closest to the identifiers used by VS Code snippet files.
|
||||
|
||||
For resilience, the lookup layer may also accept:
|
||||
|
||||
- a lowercase `getLanguageName()` value;
|
||||
- explicit aliases only where ecode and VS Code identifiers are known to differ.
|
||||
|
||||
Keep alias resolution in one helper. Do not distribute special cases through the autocomplete
|
||||
pipeline.
|
||||
|
||||
An absent `scope` in a `.code-snippets` file means global. A present scope is split on commas,
|
||||
trimmed, normalized, and indexed under every listed language.
|
||||
|
||||
## 7. Data model
|
||||
|
||||
Use eepp containers and namespace conventions:
|
||||
|
||||
- `using namespace EE;`
|
||||
- `UnorderedMap` / `UnorderedSet` instead of their `std::` equivalents;
|
||||
- `SmallVector` for fields normally containing one or a few entries, such as prefixes and scopes.
|
||||
|
||||
Suggested logical model:
|
||||
|
||||
```cpp
|
||||
enum class UserSnippetSource {
|
||||
User,
|
||||
VSCodeProject,
|
||||
EcodeProject,
|
||||
};
|
||||
|
||||
struct UserSnippetDefinition {
|
||||
std::string name;
|
||||
SmallVector<std::string, 2> prefixes;
|
||||
std::string body;
|
||||
std::string description;
|
||||
SmallVector<std::string, 2> scopes;
|
||||
std::string sourcePath;
|
||||
UserSnippetSource source;
|
||||
};
|
||||
```
|
||||
|
||||
The exact inline capacities should be confirmed with the available `SmallVector` implementation
|
||||
and typical object size. Do not put the body into every prefix index entry.
|
||||
|
||||
The store should publish an immutable snapshot containing:
|
||||
|
||||
- the owning definition vector;
|
||||
- global definition indices;
|
||||
- language-to-definition-index mappings;
|
||||
- any lightweight prefix index proven useful by profiling.
|
||||
|
||||
Start with a per-language vector scan on a worker thread. Typical personal collections are small,
|
||||
and even large snippet packs contain only thousands of entries. Do not introduce a trie before a
|
||||
measured need exists. Avoid copying snippet bodies while scanning; return matching definition
|
||||
indices and materialize only the limited set of suggestions that can be displayed or ranked.
|
||||
|
||||
If a shared immutable snapshot is used to guarantee background-query lifetime, its heap ownership
|
||||
is justified by concurrent reloads. Keep snapshot swaps coarse and cheap.
|
||||
|
||||
## 8. File loading and reload lifecycle
|
||||
|
||||
### 8.1 Initial loading
|
||||
|
||||
`AutoCompletePlugin::load()` should construct/configure the store and schedule initial user snippet
|
||||
loading on `mThreadPool`. Project sources are added once the current workspace is known.
|
||||
|
||||
No directory scan, file read, JSON parse, or snippet-pack indexing may run on the main UI thread.
|
||||
Publishing a completed snapshot and invalidating an editor are appropriate main-thread work.
|
||||
|
||||
### 8.2 Workspace changes
|
||||
|
||||
Add one normalized `setWorkspaceFolder()` path in `AutoCompletePlugin` or `UserSnippetStore` and
|
||||
call it from the relevant project lifecycle hooks:
|
||||
|
||||
- `onLoadProject()` for a project already open when the plugin subscribes;
|
||||
- `WorkspaceFolderChanged` for later project changes.
|
||||
|
||||
The setter must be idempotent because both paths may report the same workspace. When the workspace
|
||||
changes:
|
||||
|
||||
- remove definitions from the old project's `.vscode` and `.ecode` sources;
|
||||
- scan the new project sources asynchronously;
|
||||
- preserve user snippets;
|
||||
- invalidate stale jobs with a generation counter or equivalent lifetime token;
|
||||
- clear project definitions when the workspace becomes empty.
|
||||
|
||||
### 8.3 Filesystem events
|
||||
|
||||
Override `onFileSystemEvent()` in `AutoCompletePlugin` and first preserve the base behavior for
|
||||
`autocomplete.json`.
|
||||
|
||||
For recognized snippet locations, handle all applicable events:
|
||||
|
||||
- created: parse and add the file;
|
||||
- modified: parse and replace that file's definitions;
|
||||
- deleted: remove that file's definitions;
|
||||
- moved/renamed: remove the old identity and load the new path when exposed by the event API.
|
||||
|
||||
Debounce or coalesce bursts from editors that save through temporary files and renames. Schedule
|
||||
file reads and parsing on the worker pool.
|
||||
|
||||
Keep a per-file record with a content hash or equivalent so unchanged notifications do not rebuild
|
||||
the snapshot. On a malformed modification:
|
||||
|
||||
- retain the last known valid definitions for that file;
|
||||
- log one useful diagnostic;
|
||||
- replace the old definitions only after a complete valid parse succeeds.
|
||||
|
||||
On initial load, an invalid file contributes nothing.
|
||||
|
||||
## 9. Suggestion model changes
|
||||
|
||||
Refactor `AutoCompletePlugin::Suggestion` before adding user definitions. It needs distinct
|
||||
concepts for:
|
||||
|
||||
- display label;
|
||||
- filtering text or matched prefix;
|
||||
- insertion text;
|
||||
- stable identity/source;
|
||||
- explicit replacement range;
|
||||
- whether an LSP server already filtered the item.
|
||||
|
||||
Avoid changing LSP behavior unintentionally. One possible extension is:
|
||||
|
||||
```cpp
|
||||
enum class SuggestionSource {
|
||||
LocalSymbol,
|
||||
LSP,
|
||||
UserSnippet,
|
||||
SnippetChoice,
|
||||
};
|
||||
```
|
||||
|
||||
Add a stable identity only where needed. LSP items can keep their existing semantics, while user
|
||||
snippet identity should include the source path/name or a snapshot-local ID plus source generation.
|
||||
|
||||
Replace text-only duplicate detection. Local symbols can still deduplicate by text, but two user
|
||||
snippets with the same prefix must coexist. Do not make one global `operator==` silently encode
|
||||
different source-specific rules; use an explicit deduplication helper or comparison key.
|
||||
|
||||
Update fuzzy matching so:
|
||||
|
||||
- local symbols are fuzzy matched as today;
|
||||
- user snippets are matched against their prefixes;
|
||||
- LSP snippets can retain server-filtered behavior when necessary;
|
||||
- user snippet kind alone never bypasses pattern matching;
|
||||
- source priority is a tie-breaker, not a substitute for match score.
|
||||
|
||||
VS Code documents substring-style prefix matching, including abbreviated matches such as `fc` for
|
||||
`for-const`. Reuse `String::fuzzyMatchSimple()` if its behavior produces the expected ordering, and
|
||||
add focused tests before inventing another matcher.
|
||||
|
||||
## 10. Completion pipeline integration
|
||||
|
||||
Introduce a shared helper that gathers non-LSP completion sources for an editor and pattern. It
|
||||
should compose:
|
||||
|
||||
- document or language symbol cache entries;
|
||||
- matching global user snippets;
|
||||
- matching current-language user snippets.
|
||||
|
||||
Use the same helper from:
|
||||
|
||||
- `runUpdateSuggestions()` before or while requesting LSP completion;
|
||||
- `processCodeCompletion()` when an asynchronous LSP response arrives.
|
||||
|
||||
This prevents user snippets from disappearing when the LSP response replaces the popup contents.
|
||||
|
||||
Requirements:
|
||||
|
||||
- user snippets work even if no LSP server/capability exists;
|
||||
- user snippets work even if local symbol caches are empty;
|
||||
- Mod+Space can show current-language/global snippets when the partial symbol is empty;
|
||||
- typing a short snippet prefix is not blocked by the current three-character fallback threshold;
|
||||
- LSP requests continue to run away from the main UI thread;
|
||||
- stale completion responses must not bind to a destroyed editor or an obsolete snippet snapshot.
|
||||
|
||||
For an empty Mod+Space pattern, show snippets ordered by source priority and name, capped according
|
||||
to the existing popup behavior. Phase 2's dedicated picker will provide exhaustive browsing.
|
||||
|
||||
## 11. Prefix replacement and insertion
|
||||
|
||||
### 11.1 Explicit replacement ranges
|
||||
|
||||
When a user snippet matches a prefix, compute a replacement range ending at the cursor and
|
||||
covering that exact prefix in UTF-32 document coordinates. Store that range or enough activation
|
||||
metadata in the suggestion.
|
||||
|
||||
Do not call `delete-to-previous-word` for user snippets. This is required for prefixes such as:
|
||||
|
||||
- `for-const`;
|
||||
- `log!`;
|
||||
- punctuation-heavy markup triggers;
|
||||
- prefixes whose boundaries differ from `mSymbolPattern`.
|
||||
|
||||
Keep the existing LSP-provided `textEdit.range` behavior unchanged.
|
||||
|
||||
### 11.2 Multiple cursors
|
||||
|
||||
The primary cursor drives matching and popup selection. On insertion, evaluate the selected prefix
|
||||
at every cursor:
|
||||
|
||||
- if the same prefix is immediately before that cursor, replace it;
|
||||
- if the cursor has a selection, replace the selection and expose it through `TM_SELECTED_TEXT`;
|
||||
- if neither applies, insert at the cursor without deleting unrelated text.
|
||||
|
||||
Parse and prepare the body separately for every cursor so variables, indentation, selected text,
|
||||
and cursor index can differ. Continue using the existing snippet session to group equivalent tab
|
||||
stops across all insertions.
|
||||
|
||||
### 11.3 Shared insertion helper
|
||||
|
||||
Extract the snippet branch of `pickSuggestion()` into a reusable helper, for example:
|
||||
|
||||
```cpp
|
||||
void insertSnippet( UICodeEditor*, std::string_view body,
|
||||
const SnippetActivation& activation );
|
||||
```
|
||||
|
||||
Both LSP snippets and user snippets should call this helper. It should own:
|
||||
|
||||
- collecting selections;
|
||||
- constructing per-selection variables;
|
||||
- preparing contextual indentation;
|
||||
- parsing bodies;
|
||||
- deleting/replacing activation ranges;
|
||||
- inserting text;
|
||||
- starting the snippet session;
|
||||
- showing first-stop choices.
|
||||
|
||||
Keep plain-text completion insertion separate. Preserve LSP range behavior and existing
|
||||
multi-cursor behavior through regression tests/manual validation.
|
||||
|
||||
## 12. Contextual indentation
|
||||
|
||||
VS Code snippet bodies commonly use tabs for relative indentation. Add a body-preparation step
|
||||
before `SnippetParser::parse()` for each insertion:
|
||||
|
||||
1. Normalize body-array joins to `\n` at load time.
|
||||
2. Determine the insertion line's leading indentation.
|
||||
3. Prefix each body line after the first with that base indentation.
|
||||
4. Translate leading snippet indentation according to the document's configured indentation style
|
||||
and width.
|
||||
5. Preserve non-leading tabs and spaces as literal snippet content.
|
||||
6. Parse the prepared body afterward so tab-stop codepoint offsets match the actual inserted text.
|
||||
|
||||
Use existing `TextDocument` indentation helpers where available. Do not reimplement tab/space
|
||||
policy locally if the editor already exposes it.
|
||||
|
||||
Test insertion:
|
||||
|
||||
- at column zero;
|
||||
- inside an indented block;
|
||||
- with tabs configured;
|
||||
- with spaces configured;
|
||||
- at multiple cursors with different base indentation;
|
||||
- with placeholders spanning multiple lines.
|
||||
|
||||
Exact byte-for-byte VS Code whitespace parity is not required, but common code snippets must insert
|
||||
with structurally correct indentation.
|
||||
|
||||
## 13. Variables
|
||||
|
||||
Keep `snippetVariables()` as the shared variable provider for LSP and user snippets. Extend it
|
||||
incrementally rather than creating a user-only provider.
|
||||
|
||||
Phase 1 must preserve the currently supported variables:
|
||||
|
||||
- `TM_SELECTED_TEXT`
|
||||
- `TM_CURRENT_LINE`
|
||||
- `TM_CURRENT_WORD`
|
||||
- `TM_LINE_INDEX`
|
||||
- `TM_LINE_NUMBER`
|
||||
- `TM_FILENAME`
|
||||
- `TM_FILENAME_BASE`
|
||||
- `TM_DIRECTORY`
|
||||
- `TM_FILEPATH`
|
||||
|
||||
Phase 2 should add the high-value, straightforward variables:
|
||||
|
||||
- `RELATIVE_FILEPATH`
|
||||
- `WORKSPACE_NAME`
|
||||
- `WORKSPACE_FOLDER`
|
||||
- `CLIPBOARD`
|
||||
- `CURSOR_INDEX`
|
||||
- `CURSOR_NUMBER`
|
||||
- current date/time variables;
|
||||
- `RANDOM`, `RANDOM_HEX`, and `UUID` if suitable engine utilities already exist;
|
||||
- `LINE_COMMENT`, `BLOCK_COMMENT_START`, and `BLOCK_COMMENT_END` when syntax definitions expose
|
||||
reliable comment delimiters.
|
||||
|
||||
Do not create new time, random, UUID, clipboard, or syntax-comment infrastructure solely for
|
||||
snippets. Use existing services or defer the variable.
|
||||
|
||||
## 14. Phase 1 - Core user-defined snippets
|
||||
|
||||
Phase 1 is the minimum release intended to satisfy the central request in issue #111.
|
||||
|
||||
**Phase status: implemented.** The unchecked items below identify hardening or exact architectural
|
||||
follow-ups, not blockers for the currently working feature.
|
||||
|
||||
### 14.1 Store and loader
|
||||
|
||||
- [x] Add `UserSnippetDefinition` and `UserSnippetStore`.
|
||||
- [x] Parse JSONC core fields, comments, and trailing commas.
|
||||
- [x] Load user language/global sources.
|
||||
- [x] Load `.vscode` and `.ecode` project sources.
|
||||
- [x] Build immutable language/global indexes.
|
||||
- [x] Preserve last-known-good per-file data on reload errors.
|
||||
- [x] Add source-aware diagnostics without logging bodies.
|
||||
|
||||
### 14.2 Autocomplete integration
|
||||
|
||||
- [x] Refactor suggestion identity/filtering.
|
||||
- [x] Prevent user snippet kind from bypassing prefix matching.
|
||||
- [x] Preserve same-prefix snippets as distinct candidates.
|
||||
- [x] Merge user snippets into local and LSP completion paths.
|
||||
- [x] Support short prefixes and empty-pattern Mod+Space invocation.
|
||||
- [x] Show snippet name/description and a snippet icon/kind using the existing rendering path.
|
||||
- [x] Apply project/user source priority only between equally matching user snippets, without
|
||||
promoting them above normal completions.
|
||||
|
||||
### 14.3 Insertion
|
||||
|
||||
- [x] Reuse the shared snippet insertion/session path for LSP and user snippets.
|
||||
- [x] Add exact matched-prefix replacement independent of word boundaries.
|
||||
- [x] Adapt multiline indentation per cursor.
|
||||
- [x] Reuse existing variables and sessions.
|
||||
- [x] Preserve multi-cursor insertion and choice behavior.
|
||||
|
||||
### 14.4 Lifecycle
|
||||
|
||||
- [x] Load on the worker pool.
|
||||
- [x] Handle current and changed workspaces.
|
||||
- [x] Watch create/modify/delete/move events.
|
||||
- [x] Ignore stale background work during reload, workspace change, and shutdown.
|
||||
- [x] Avoid allocation and worker scheduling for unrelated filesystem events.
|
||||
- [ ] Optionally debounce repeated malformed-file notifications; unchanged valid files are already
|
||||
suppressed by content hash.
|
||||
|
||||
### 14.5 Phase 1 acceptance criteria
|
||||
|
||||
- [x] A copied VS Code language snippet file works from ecode's user snippets directory.
|
||||
- [x] An existing `.vscode/*.code-snippets` file works without modification.
|
||||
- [x] A `.ecode/*.code-snippets` project file works.
|
||||
- [x] String and array forms of `prefix` and `body` work.
|
||||
- [x] Global and language scopes work.
|
||||
- [x] Snippets appear while typing and with Mod+Space, with or without an LSP server.
|
||||
- [x] Two snippets with the same prefix remain independently selectable.
|
||||
- [x] Punctuation-containing prefixes replace exactly the matched prefix.
|
||||
- [x] Tab stops, placeholders, mirrors, choices, variables, and multiple cursors continue to work.
|
||||
- [x] Multiline snippets respect the current indentation style.
|
||||
- [x] Editing, creating, renaming, and deleting a snippet file updates suggestions without
|
||||
restarting ecode.
|
||||
- [x] Invalid in-progress JSON does not destroy the last valid loaded definitions.
|
||||
- [x] No filesystem or JSON work occurs on the main UI thread.
|
||||
|
||||
## 15. Phase 2 - Editor usability and broader practical compatibility
|
||||
|
||||
Phase 2 is part of the intended feature, but should be implemented after Phase 1 is usable and has
|
||||
been tested with real snippet collections.
|
||||
|
||||
**Phase status: not started and no longer required for the initial release.** Each subsection can
|
||||
be accepted or rejected independently based on whether the workflow is valuable to ecode users.
|
||||
|
||||
### 15.1 Dedicated Insert Snippet command
|
||||
|
||||
Add an `insert-snippet` editor/application command that opens a searchable list of snippets valid
|
||||
for the current language and file.
|
||||
|
||||
The list should show:
|
||||
|
||||
- snippet name;
|
||||
- prefixes;
|
||||
- description;
|
||||
- source when needed to disambiguate duplicates.
|
||||
|
||||
Picking an entry inserts it without requiring a typed prefix. Reuse existing list/model helpers or
|
||||
the Universal Locator where that produces a natural ecode interaction. Do not build a second
|
||||
autocomplete popup implementation.
|
||||
|
||||
Add a configurable keybinding entry, but no default binding is required if the command is readily
|
||||
available from the command palette.
|
||||
|
||||
### 15.2 Optional exact-prefix Tab completion
|
||||
|
||||
Add an autocomplete setting such as:
|
||||
|
||||
```json
|
||||
"snippets": {
|
||||
"enabled": true,
|
||||
"tab_completion": false
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, key handling order must be:
|
||||
|
||||
1. active snippet session: navigate to the next tab stop;
|
||||
2. visible completion popup: accept the selected suggestion;
|
||||
3. no popup and exact snippet prefix before the cursor: expand it;
|
||||
4. otherwise: allow the editor's normal Tab command.
|
||||
|
||||
If multiple definitions have the same exact prefix, open a choice list instead of selecting one
|
||||
arbitrarily. Shift+Tab must never start a new snippet expansion.
|
||||
|
||||
Use command/keybinding resolution rather than hard-coded key codes.
|
||||
|
||||
### 15.3 Configure Snippets command
|
||||
|
||||
Add a command that opens or creates the appropriate user snippet file for the current language.
|
||||
Creation should use a small commented starter document valid as JSONC. Do not overwrite an existing
|
||||
file.
|
||||
|
||||
Project snippet creation can be a separate command or later follow-up; user-language configuration
|
||||
is the priority.
|
||||
|
||||
### 15.4 File pattern scopes
|
||||
|
||||
Implement `include` and `exclude` using the engine's existing glob/path matching facilities.
|
||||
|
||||
- Filename-only patterns match the filename.
|
||||
- Path patterns match the normalized full or workspace-relative path, matching the documented VS
|
||||
Code behavior as closely as practical.
|
||||
- `exclude` wins when both include and exclude match.
|
||||
- Pattern matching occurs before suggestion materialization.
|
||||
|
||||
Cache compiled pattern data in the immutable snapshot. Do not compile glob/regex patterns on every
|
||||
keystroke.
|
||||
|
||||
### 15.5 Common variables and modifiers
|
||||
|
||||
- Add variables listed in Section 13 when supported by existing ecode/eepp services.
|
||||
- Add `camelcase`, `pascalcase`, `snakecase`, and `kebabcase` transform format modifiers.
|
||||
- Add tests for Unicode behavior where the chosen string helpers define it clearly.
|
||||
|
||||
### 15.6 Phase 2 acceptance criteria
|
||||
|
||||
- Users can browse and insert snippets without typing a prefix.
|
||||
- Optional Tab completion does not break indentation or active snippet navigation.
|
||||
- Duplicate exact prefixes prompt for a choice.
|
||||
- `include` and `exclude` filter snippets predictably.
|
||||
- Common workspace, clipboard, cursor, time, and casing transformations behave as documented where
|
||||
supported.
|
||||
- Users can locate/create their language snippet file from ecode without learning platform paths.
|
||||
|
||||
## 16. Phase 3 - Optional compatibility work
|
||||
|
||||
Phase 3 is explicitly optional. Do not block issue #111 or the initial user-defined snippet release
|
||||
on this work.
|
||||
|
||||
**Phase status: not started; defer unless a concrete compatibility issue requires it.**
|
||||
|
||||
Only implement an item after identifying a real snippet pack, user workflow, or compatibility bug
|
||||
that needs it.
|
||||
|
||||
Possible work:
|
||||
|
||||
- placeholder transforms that reevaluate transformed mirrors after editing a tab stop;
|
||||
- `isFileTemplate` and a dedicated Fill File with Snippet workflow;
|
||||
- closer JavaScript-regex compatibility where `EE::System::RegEx` differs materially;
|
||||
- additional VS Code variables introduced after the initial implementation;
|
||||
- configurable external snippet directories;
|
||||
- explicit VS Code/VSCodium profile import;
|
||||
- importing Sublime or TextMate container formats into the internal definition model;
|
||||
- snippet extension/package manifests;
|
||||
- per-snippet keybindings or context expressions.
|
||||
|
||||
Placeholder transforms are the largest runtime item. They require storing transform metadata on
|
||||
occurrences, identifying the authoritative first placeholder occurrence, and updating transformed
|
||||
mirrors when the source placeholder changes or when navigation leaves it. This must be designed as
|
||||
session behavior, not faked as a one-time parse transform.
|
||||
|
||||
TextMate shell interpolation remains out of scope even in Phase 3 unless a separate security design
|
||||
is explicitly approved.
|
||||
|
||||
## 17. Tests
|
||||
|
||||
**Current status:** all existing `SnippetParser` tests and the focused `UserSnippetStore` suite
|
||||
pass. Core parsing, scope matching, duplicate triggers, last-known-good replacement, and source
|
||||
removal are covered. The lists below remain the desired broader coverage rather than a claim that
|
||||
every item is automated today.
|
||||
|
||||
### 17.1 Loader unit tests
|
||||
|
||||
Add focused tests for:
|
||||
|
||||
- JSONC comments and trailing comments;
|
||||
- language `.json` default scope;
|
||||
- global `.code-snippets` scope;
|
||||
- comma-separated scope trimming;
|
||||
- string and array prefixes;
|
||||
- string and array bodies;
|
||||
- optional description;
|
||||
- invalid root JSON;
|
||||
- invalid individual definitions without rejecting valid siblings;
|
||||
- empty prefixes;
|
||||
- duplicate prefixes and names from different sources;
|
||||
- UTF-8 names, descriptions, prefixes, and bodies;
|
||||
- body arrays joined exactly once with `\n`;
|
||||
- unsupported recognized fields remaining non-fatal;
|
||||
- no snippet-body text in diagnostics, where diagnostics can be inspected.
|
||||
|
||||
Keep JSON-to-definition parsing isolated enough to test without constructing a `PluginManager` or
|
||||
GUI. If necessary, expose a small pure parser function in the same module and keep filesystem/store
|
||||
coordination around it.
|
||||
|
||||
### 17.2 Matching/index tests
|
||||
|
||||
Test:
|
||||
|
||||
- global plus current-language results;
|
||||
- no snippets from unrelated languages;
|
||||
- prefix abbreviation/fuzzy ranking;
|
||||
- source priority tie-breaking;
|
||||
- same-prefix definitions remaining distinct;
|
||||
- empty-pattern results;
|
||||
- punctuation prefixes;
|
||||
- file-pattern filtering when Phase 2 is implemented;
|
||||
- snapshot replacement without dangling indices.
|
||||
|
||||
### 17.3 Parser regression tests
|
||||
|
||||
Keep all existing `SnippetParser` tests. Add tests only when body syntax or transform modifiers are
|
||||
changed; do not duplicate loader tests in the parser suite.
|
||||
|
||||
### 17.4 Integration/manual tests
|
||||
|
||||
Because editor/plugin UI integration is difficult to instantiate in unit tests, maintain a small
|
||||
manual fixture covering:
|
||||
|
||||
- one user language file;
|
||||
- one global scoped file;
|
||||
- one `.vscode` project file;
|
||||
- duplicate and punctuation prefixes;
|
||||
- multiline indentation;
|
||||
- choices and mirrors;
|
||||
- two cursors at different indentation levels;
|
||||
- invalid JSON followed by recovery;
|
||||
- workspace switching;
|
||||
- no LSP, an LSP returning no completions, and an LSP returning snippets/completions.
|
||||
|
||||
Verify that Mod+Space and configured shortcuts use `KeyBindings` matching helpers rather than raw
|
||||
shortcut equality or hard-coded keys.
|
||||
|
||||
## 18. Build and validation workflow
|
||||
|
||||
When implementation begins:
|
||||
|
||||
1. Add new source files to both `premake4.lua` and `premake5.lua` where the unit-test target needs
|
||||
them. Confirm the ecode target's normal file glob includes them.
|
||||
2. Add unit-test sources under `src/tests/unit_tests/`.
|
||||
3. Run `clang-format` on every modified C/C++ file.
|
||||
4. Regenerate project files using the repository's required debug/ASan premake invocation.
|
||||
5. Build ecode and the unit-test target.
|
||||
6. Run focused snippet/user-snippet tests through `projects/scripts/xvfb-run-eepp`.
|
||||
7. Run the complete unit-test suite before handoff.
|
||||
8. Run `git diff --check`.
|
||||
9. Perform the manual editor matrix from Section 17.4.
|
||||
|
||||
## 19. Performance and allocation audit
|
||||
|
||||
Before completing each phase, explicitly review:
|
||||
|
||||
- whether file I/O or JSON parsing can reach the UI thread;
|
||||
- whether every keystroke copies all snippet bodies;
|
||||
- whether prefix arrays duplicate bodies in storage;
|
||||
- whether immutable snapshots have clear ownership during asynchronous matching;
|
||||
- whether reload jobs capture large vectors or strings by value unnecessarily;
|
||||
- whether worker lambdas use move captures for owned buffers;
|
||||
- whether stale jobs can publish after workspace change or plugin shutdown;
|
||||
- whether pattern compilation happens at load time rather than query time;
|
||||
- whether popup result caps are applied before expensive suggestion materialization;
|
||||
- whether repeated same-file events rebuild an unchanged snapshot;
|
||||
- whether logging accidentally copies or exposes snippet bodies.
|
||||
|
||||
Expected justified heap allocations include loaded definition strings, per-language indexes,
|
||||
immutable snapshots needed across worker jobs, and the small limited set of materialized popup
|
||||
suggestions. Avoid per-frame or draw-time snippet work entirely.
|
||||
|
||||
## 20. Documentation
|
||||
|
||||
**Status: complete for Phase 1.** The ecode documentation repository contains `docs/snippets.md`,
|
||||
linked from `docs/autocomplete.md`, with locations, examples, supported syntax and variables,
|
||||
runtime behavior, troubleshooting, and a detailed VS Code compatibility comparison. Update that
|
||||
document alongside each Phase 2 compatibility change.
|
||||
|
||||
Ship a concise user-facing document or configuration section containing:
|
||||
|
||||
- supported file locations;
|
||||
- a minimal language-specific example;
|
||||
- a global scoped example;
|
||||
- the supported fields;
|
||||
- how to invoke suggestions and the Insert Snippet command;
|
||||
- whether Tab completion is enabled;
|
||||
- supported variables;
|
||||
- known compatibility limitations;
|
||||
- the fact that shell interpolation is intentionally unsupported.
|
||||
|
||||
Use “VS Code JSON/JSONC snippet file compatible” until the optional compatibility gaps have been
|
||||
closed. Avoid the unqualified statement “supports all VS Code snippets.”
|
||||
|
||||
## 21. Recommended execution order
|
||||
|
||||
Implement in this order to keep each change reviewable and testable:
|
||||
|
||||
1. Introduce the pure definition model and JSONC file parser with unit tests.
|
||||
2. Add immutable storage, language/global indexes, source identity, and query tests.
|
||||
3. Refactor `Suggestion` identity/filtering without changing visible existing behavior.
|
||||
4. Add user snippet matching to the non-LSP completion pipeline.
|
||||
5. Merge the same source into LSP response processing through a shared helper.
|
||||
6. Extract shared snippet insertion and add exact prefix replacement.
|
||||
7. Add per-cursor indentation preparation and multi-cursor tests/manual validation.
|
||||
8. Add user/project discovery and initial asynchronous loading.
|
||||
9. Add workspace changes, filesystem reload, last-known-good behavior, and stale-job protection.
|
||||
10. Complete Phase 1 acceptance testing and release it for real-world snippet-pack testing.
|
||||
11. Add the Insert Snippet and Configure Snippets commands.
|
||||
12. Add optional exact-prefix Tab completion.
|
||||
13. Add file-pattern filters and high-value variables/modifiers.
|
||||
14. Reassess optional Phase 3 only from observed incompatibilities.
|
||||
|
||||
Items 1-10 are complete for the current Phase 1 implementation, subject to the hardening notes in
|
||||
Section 0. Items 11-13 are uncommitted product choices from Phase 2. Item 14 remains optional.
|
||||
|
||||
The implementation can stop here if the current autocomplete-driven workflow is sufficient. Phase
|
||||
2 should be selected feature-by-feature from actual feedback. Phase 3 is not part of the completion
|
||||
definition for issue #111.
|
||||
57
.ecode/c-cpp.code-snippets
Normal file
57
.ecode/c-cpp.code-snippets
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"Main Function": {
|
||||
"scope": "c,cpp",
|
||||
"prefix": ["main", "mainfn"],
|
||||
"body": [
|
||||
"int main(int argc, char** argv) {",
|
||||
"\t${1:(void)argc;}",
|
||||
"\t${2:(void)argv;}",
|
||||
"\t$0",
|
||||
"\treturn 0;",
|
||||
"}"
|
||||
],
|
||||
"description": "Program entry point"
|
||||
},
|
||||
"Indexed For Loop": {
|
||||
"scope": "c,cpp",
|
||||
"prefix": ["fori", "for-index"],
|
||||
"body": [
|
||||
"for (${1:size_t} ${2:i} = 0; $2 < ${3:count}; ++$2) {",
|
||||
"\t$0",
|
||||
"}"
|
||||
],
|
||||
"description": "Indexed loop with linked iterator placeholders"
|
||||
},
|
||||
"Include Header": {
|
||||
"scope": "c,cpp",
|
||||
"prefix": "inc",
|
||||
"body": "#include ${1|<stdio.h>,<stdlib.h>,<string.h>,\"header.h\"|}$0",
|
||||
"description": "Include a common system header or a local header"
|
||||
},
|
||||
"Guard Clause": {
|
||||
"scope": "c,cpp",
|
||||
"prefix": "guard",
|
||||
"body": [
|
||||
"if (${1:condition}) {",
|
||||
"\t${2:return;}",
|
||||
"}",
|
||||
"$0"
|
||||
],
|
||||
"description": "Early-return guard clause"
|
||||
},
|
||||
"C++ Class": {
|
||||
"scope": "cpp",
|
||||
"prefix": "class",
|
||||
"body": [
|
||||
"class ${1:${TM_FILENAME_BASE}} {",
|
||||
" public:",
|
||||
"\t$1(${2});",
|
||||
"\t~$1();",
|
||||
"",
|
||||
" private:",
|
||||
"\t$0",
|
||||
"};"
|
||||
],
|
||||
"description": "Class named after the current file with linked placeholders"
|
||||
}
|
||||
}
|
||||
@@ -1935,7 +1935,9 @@ solution "eepp"
|
||||
includedirs { "src/modules/eterm/include/", "src/thirdparty" }
|
||||
language "C++"
|
||||
files { "src/tests/unit_tests/*.cpp",
|
||||
"src/tools/ecode/plugins/autocomplete/snippetparser.cpp" }
|
||||
"src/tools/ecode/jsonhelper.cpp",
|
||||
"src/tools/ecode/plugins/autocomplete/snippetparser.cpp",
|
||||
"src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" }
|
||||
eepp_module_backward_add( false )
|
||||
build_link_configuration( "eepp-unit_tests", true )
|
||||
|
||||
|
||||
@@ -1963,7 +1963,9 @@ workspace "eepp"
|
||||
incdirs { "src/modules/eterm/include/", "src/thirdparty" }
|
||||
language "C++"
|
||||
files { "src/tests/unit_tests/*.cpp",
|
||||
"src/tools/ecode/plugins/autocomplete/snippetparser.cpp" }
|
||||
"src/tools/ecode/jsonhelper.cpp",
|
||||
"src/tools/ecode/plugins/autocomplete/snippetparser.cpp",
|
||||
"src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" }
|
||||
eepp_module_backward_add( false )
|
||||
build_link_configuration( "eepp-unit_tests", true )
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ void addJSON() {
|
||||
{ "JSON",
|
||||
{
|
||||
"%.json$",
|
||||
"%.cson$"
|
||||
"%.cson$",
|
||||
"%.jsonc$",
|
||||
"%.code%-snippets$",
|
||||
"%.ipynb$",
|
||||
"%.webmanifest$",
|
||||
"%.cps$",
|
||||
|
||||
124
src/tests/unit_tests/usersnippetstore_tests.cpp
Normal file
124
src/tests/unit_tests/usersnippetstore_tests.cpp
Normal file
@@ -0,0 +1,124 @@
|
||||
#include "../../tools/ecode/plugins/autocomplete/usersnippetstore.hpp"
|
||||
#include "utest.hpp"
|
||||
|
||||
using namespace ecode;
|
||||
|
||||
UTEST( UserSnippetStore, parsesJSONCAndCoreFields ) {
|
||||
auto parsed = UserSnippetStore::parseFile(
|
||||
R"json({
|
||||
// Language snippet file
|
||||
"For Loop": {
|
||||
"prefix": ["for", "for-const"],
|
||||
"body": ["for (const ${1:item} of ${2:items}) {", "\t$0", "}"],
|
||||
"description": "Loop over values"
|
||||
}, // trailing commas are valid JSONC
|
||||
})json",
|
||||
"javascript.json", UserSnippetSource::User, "javascript" );
|
||||
ASSERT_TRUE( parsed.valid );
|
||||
ASSERT_EQ( 1u, parsed.snippets.size() );
|
||||
const auto& snippet = parsed.snippets[0];
|
||||
EXPECT_STDSTREQ( "For Loop", snippet.name );
|
||||
ASSERT_EQ( 2u, snippet.prefixes.size() );
|
||||
EXPECT_STDSTREQ( "for-const", snippet.prefixes[1] );
|
||||
EXPECT_STDSTREQ( "for (const ${1:item} of ${2:items}) {\n\t$0\n}", snippet.body );
|
||||
EXPECT_STDSTREQ( "Loop over values", snippet.description );
|
||||
ASSERT_EQ( 1u, snippet.scopes.size() );
|
||||
EXPECT_STDSTREQ( "javascript", snippet.scopes[0] );
|
||||
|
||||
auto emptyFirstLine = UserSnippetStore::parseFile(
|
||||
R"json({ "Lines": { "prefix": "lines", "body": ["", "second"] } })json", "lines.json",
|
||||
UserSnippetSource::User, "text" );
|
||||
ASSERT_EQ( 1u, emptyFirstLine.snippets.size() );
|
||||
EXPECT_STDSTREQ( "\nsecond", emptyFirstLine.snippets[0].body );
|
||||
}
|
||||
|
||||
UTEST( UserSnippetStore, parsesGlobalAndScopedSnippets ) {
|
||||
auto parsed = UserSnippetStore::parseFile(
|
||||
R"json({
|
||||
"Global": { "prefix": "global", "body": "global$0" },
|
||||
"Scoped": {
|
||||
"scope": " cpp, C ",
|
||||
"prefix": "loop",
|
||||
"body": "loop$0"
|
||||
}
|
||||
})json",
|
||||
"shared.code-snippets", UserSnippetSource::User );
|
||||
ASSERT_TRUE( parsed.valid );
|
||||
ASSERT_EQ( 2u, parsed.snippets.size() );
|
||||
EXPECT_TRUE( parsed.snippets[0].scopes.empty() );
|
||||
ASSERT_EQ( 2u, parsed.snippets[1].scopes.size() );
|
||||
EXPECT_STDSTREQ( "cpp", parsed.snippets[1].scopes[0] );
|
||||
EXPECT_STDSTREQ( "c", parsed.snippets[1].scopes[1] );
|
||||
}
|
||||
|
||||
UTEST( UserSnippetStore, skipsInvalidDefinitionsOnly ) {
|
||||
auto parsed = UserSnippetStore::parseFile(
|
||||
R"json({
|
||||
"Missing Body": { "prefix": "missing" },
|
||||
"Bad Prefix": { "prefix": ["", 1], "body": "bad" },
|
||||
"Valid": { "prefix": "ok", "body": "value" }
|
||||
})json",
|
||||
"test.json", UserSnippetSource::User, "cpp" );
|
||||
ASSERT_TRUE( parsed.valid );
|
||||
ASSERT_EQ( 1u, parsed.snippets.size() );
|
||||
EXPECT_STDSTREQ( "Valid", parsed.snippets[0].name );
|
||||
EXPECT_EQ( 2u, parsed.diagnostics.size() );
|
||||
}
|
||||
|
||||
UTEST( UserSnippetStore, rejectsMalformedFiles ) {
|
||||
auto parsed =
|
||||
UserSnippetStore::parseFile( "{ invalid", "bad.json", UserSnippetSource::User, "cpp" );
|
||||
EXPECT_FALSE( parsed.valid );
|
||||
EXPECT_TRUE( parsed.snippets.empty() );
|
||||
ASSERT_EQ( 1u, parsed.diagnostics.size() );
|
||||
EXPECT_TRUE( parsed.diagnostics[0].find( "{ invalid" ) == std::string::npos );
|
||||
}
|
||||
|
||||
UTEST( UserSnippetStore, matchesScopesPrefixesAndDuplicateTriggers ) {
|
||||
UserSnippetStore store;
|
||||
ASSERT_TRUE( store.updateFile(
|
||||
R"json({
|
||||
"First": { "prefix": ["for", "for-const"], "body": "first" },
|
||||
"Second": { "prefix": "for", "body": "second" }
|
||||
})json",
|
||||
"cpp.json", UserSnippetSource::User, "cpp" ) );
|
||||
ASSERT_TRUE(
|
||||
store.updateFile( R"json({ "Global": { "prefix": "format", "body": "global" } })json",
|
||||
"global.code-snippets", UserSnippetSource::User ) );
|
||||
|
||||
auto cpp = store.find( "CPP", "for", 10 );
|
||||
ASSERT_EQ( 3u, cpp.size() );
|
||||
EXPECT_STDSTREQ( "First", cpp[0].snippet.name );
|
||||
EXPECT_TRUE( std::any_of( cpp.begin(), cpp.end(), []( const auto& match ) {
|
||||
return match.snippet.name == "Second";
|
||||
} ) );
|
||||
EXPECT_TRUE( std::any_of( cpp.begin(), cpp.end(), []( const auto& match ) {
|
||||
return match.snippet.name == "Global";
|
||||
} ) );
|
||||
|
||||
auto rust = store.find( "rust", "for", 10 );
|
||||
ASSERT_EQ( 1u, rust.size() );
|
||||
EXPECT_STDSTREQ( "Global", rust[0].snippet.name );
|
||||
|
||||
auto punctuation = store.find( "cpp", "call(for-c", 10 );
|
||||
ASSERT_FALSE( punctuation.empty() );
|
||||
EXPECT_STDSTREQ( "for-const", punctuation[0].matchedPrefix );
|
||||
EXPECT_STDSTREQ( "for-c", punctuation[0].matchedInput );
|
||||
EXPECT_TRUE( store.find( "cpp", "unrelatedf", 10 ).empty() );
|
||||
}
|
||||
|
||||
UTEST( UserSnippetStore, keepsLastGoodFileAndRemovesSources ) {
|
||||
UserSnippetStore store;
|
||||
ASSERT_TRUE( store.updateFile( R"json({ "One": { "prefix": "one", "body": "one" } })json",
|
||||
"user.json", UserSnippetSource::User, "cpp" ) );
|
||||
ASSERT_TRUE(
|
||||
store.updateFile( R"json({ "Project": { "prefix": "project", "body": "project" } })json",
|
||||
"project.code-snippets", UserSnippetSource::VSCodeProject ) );
|
||||
EXPECT_EQ( 2u, store.size() );
|
||||
EXPECT_FALSE( store.updateFile( "{ invalid", "user.json", UserSnippetSource::User, "cpp" ) );
|
||||
EXPECT_EQ( 2u, store.size() );
|
||||
store.removeSource( UserSnippetSource::VSCodeProject );
|
||||
EXPECT_EQ( 1u, store.size() );
|
||||
EXPECT_TRUE( store.removeFile( "user.json" ) );
|
||||
EXPECT_EQ( 0u, store.size() );
|
||||
}
|
||||
87
src/tools/ecode/jsonhelper.cpp
Normal file
87
src/tools/ecode/jsonhelper.cpp
Normal file
@@ -0,0 +1,87 @@
|
||||
#include "jsonhelper.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
static size_t nextJSONToken( std::string_view contents, size_t pos ) {
|
||||
while ( pos < contents.size() ) {
|
||||
while ( pos < contents.size() && ( contents[pos] == ' ' || contents[pos] == '\t' ||
|
||||
contents[pos] == '\r' || contents[pos] == '\n' ) )
|
||||
++pos;
|
||||
if ( pos + 1 >= contents.size() || contents[pos] != '/' )
|
||||
break;
|
||||
if ( contents[pos + 1] == '/' ) {
|
||||
pos += 2;
|
||||
while ( pos < contents.size() && contents[pos] != '\n' )
|
||||
++pos;
|
||||
} else if ( contents[pos + 1] == '*' ) {
|
||||
pos += 2;
|
||||
while ( pos + 1 < contents.size() &&
|
||||
!( contents[pos] == '*' && contents[pos + 1] == '/' ) )
|
||||
++pos;
|
||||
if ( pos + 1 < contents.size() )
|
||||
pos += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string json_strip_trailing_commas( std::string_view contents ) {
|
||||
std::string sanitized;
|
||||
sanitized.reserve( contents.size() );
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
bool lineComment = false;
|
||||
bool blockComment = false;
|
||||
for ( size_t pos = 0; pos < contents.size(); ++pos ) {
|
||||
const char ch = contents[pos];
|
||||
if ( lineComment ) {
|
||||
lineComment = ch != '\n';
|
||||
sanitized.push_back( ch );
|
||||
continue;
|
||||
}
|
||||
if ( blockComment ) {
|
||||
if ( ch == '*' && pos + 1 < contents.size() && contents[pos + 1] == '/' ) {
|
||||
blockComment = false;
|
||||
sanitized += "*/";
|
||||
++pos;
|
||||
} else {
|
||||
sanitized.push_back( ch );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ( inString ) {
|
||||
sanitized.push_back( ch );
|
||||
if ( escaped )
|
||||
escaped = false;
|
||||
else if ( ch == '\\' )
|
||||
escaped = true;
|
||||
else if ( ch == '"' )
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
if ( ch == '"' ) {
|
||||
inString = true;
|
||||
sanitized.push_back( ch );
|
||||
continue;
|
||||
}
|
||||
if ( ch == '/' && pos + 1 < contents.size() ) {
|
||||
if ( contents[pos + 1] == '/' )
|
||||
lineComment = true;
|
||||
else if ( contents[pos + 1] == '*' )
|
||||
blockComment = true;
|
||||
sanitized.push_back( ch );
|
||||
continue;
|
||||
}
|
||||
if ( ch == ',' ) {
|
||||
const size_t next = nextJSONToken( contents, pos + 1 );
|
||||
if ( next < contents.size() && ( contents[next] == '}' || contents[next] == ']' ) )
|
||||
continue;
|
||||
}
|
||||
sanitized.push_back( ch );
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* Converts JSONC with trailing commas into input accepted by a strict JSON parser.
|
||||
* Removes a comma when the next token, ignoring whitespace and comments, is a closing object or
|
||||
* array delimiter. Comments, string contents, and every other character are preserved.
|
||||
*/
|
||||
std::string json_strip_trailing_commas( std::string_view contents );
|
||||
|
||||
template <typename T> static constexpr nlohmann::detail::value_t json_get_value_type() {
|
||||
if constexpr ( std::is_same_v<T, std::string> ) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string_view>
|
||||
using namespace EE::Graphics;
|
||||
using namespace EE::System;
|
||||
using json = nlohmann::json;
|
||||
@@ -20,6 +21,40 @@ using namespace std::literals;
|
||||
|
||||
namespace ecode {
|
||||
|
||||
static bool pathStartsWith( std::string_view path, std::string_view prefix ) {
|
||||
return !prefix.empty() && path.size() >= prefix.size() &&
|
||||
path.compare( 0, prefix.size(), prefix ) == 0;
|
||||
}
|
||||
|
||||
static bool pathEndsWith( std::string_view path, std::string_view suffix ) {
|
||||
return path.size() >= suffix.size() &&
|
||||
path.compare( path.size() - suffix.size(), suffix.size(), suffix ) == 0;
|
||||
}
|
||||
|
||||
static bool getSnippetPathSource( std::string_view path, std::string_view userPath,
|
||||
std::string_view vscodePath, std::string_view ecodePath,
|
||||
UserSnippetSource& source, bool& languageFiles ) {
|
||||
if ( pathStartsWith( path, userPath ) &&
|
||||
( pathEndsWith( path, ".json" ) || pathEndsWith( path, ".code-snippets" ) ) ) {
|
||||
source = UserSnippetSource::User;
|
||||
languageFiles = true;
|
||||
return true;
|
||||
}
|
||||
if ( pathEndsWith( path, ".code-snippets" ) ) {
|
||||
if ( pathStartsWith( path, vscodePath ) ) {
|
||||
source = UserSnippetSource::VSCodeProject;
|
||||
languageFiles = false;
|
||||
return true;
|
||||
}
|
||||
if ( pathStartsWith( path, ecodePath ) ) {
|
||||
source = UserSnippetSource::EcodeProject;
|
||||
languageFiles = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class AutoCompletePlugin::SnippetDocumentClient : public TextDocument::Client {
|
||||
public:
|
||||
SnippetDocumentClient( AutoCompletePlugin* plugin, TextDocument* doc ) :
|
||||
@@ -96,32 +131,44 @@ fuzzyMatchSymbols( const std::vector<const AutoCompletePlugin::SymbolsList*>& sy
|
||||
const std::string& pattern, const size_t& max ) {
|
||||
AutoCompletePlugin::SymbolsList matches;
|
||||
matches.reserve( max );
|
||||
int score = 0;
|
||||
for ( const auto& symbols : symbolsVec ) {
|
||||
size_t sourceMatches = 0;
|
||||
for ( const auto& symbol : *symbols ) {
|
||||
if ( symbol.kind == LSPCompletionItemKind::Snippet ||
|
||||
( score = String::fuzzyMatchSimple(
|
||||
pattern, symbol.text, false, symbol.kind != LSPCompletionItemKind::Text ) ) >
|
||||
0 ) {
|
||||
const bool serverFilteredSnippet =
|
||||
symbol.source == AutoCompletePlugin::Suggestion::Source::LSP &&
|
||||
symbol.kind == LSPCompletionItemKind::Snippet;
|
||||
const int score =
|
||||
serverFilteredSnippet
|
||||
? 0
|
||||
: String::fuzzyMatchSimple( pattern, symbol.text, false,
|
||||
symbol.kind != LSPCompletionItemKind::Text );
|
||||
if ( serverFilteredSnippet || score > 0 ) {
|
||||
if ( std::find( matches.begin(), matches.end(), symbol ) == matches.end() ) {
|
||||
symbol.setScore( score +
|
||||
( symbol.kind != LSPCompletionItemKind::Text ? score : 0 ) );
|
||||
matches.push_back( symbol );
|
||||
++sourceMatches;
|
||||
|
||||
if ( matches.size() >= max )
|
||||
if ( sourceMatches >= max )
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( matches.size() >= max )
|
||||
break;
|
||||
}
|
||||
|
||||
std::sort(
|
||||
matches.begin(), matches.end(),
|
||||
[]( const AutoCompletePlugin::Suggestion& left,
|
||||
const AutoCompletePlugin::Suggestion& right ) { return left.score > right.score; } );
|
||||
std::sort( matches.begin(), matches.end(),
|
||||
[]( const AutoCompletePlugin::Suggestion& left,
|
||||
const AutoCompletePlugin::Suggestion& right ) {
|
||||
if ( left.score != right.score )
|
||||
return left.score > right.score;
|
||||
if ( left.source == AutoCompletePlugin::Suggestion::Source::UserSnippet &&
|
||||
right.source == AutoCompletePlugin::Suggestion::Source::UserSnippet &&
|
||||
left.sourcePriority != right.sourcePriority )
|
||||
return left.sourcePriority > right.sourcePriority;
|
||||
return left.text < right.text;
|
||||
} );
|
||||
if ( matches.size() > max )
|
||||
matches.erase( matches.begin() + max, matches.end() );
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -154,6 +201,8 @@ AutoCompletePlugin::~AutoCompletePlugin() {
|
||||
mShuttingDown = true;
|
||||
mManager->unsubscribeMessages( this );
|
||||
unsubscribeFileSystemListener();
|
||||
while ( mSnippetJobs > 0 )
|
||||
Sys::sleep( Milliseconds( 1 ) );
|
||||
for ( auto& client : mSnippetClients )
|
||||
client.second->detach();
|
||||
mSnippetClients.clear();
|
||||
@@ -329,12 +378,163 @@ void AutoCompletePlugin::load( PluginManager* pluginManager ) {
|
||||
updateShortcuts();
|
||||
}
|
||||
|
||||
mUserSnippetsPath = pluginManager->getConfigPath() + "snippets" + FileSystem::getOSSlash();
|
||||
++mSnippetJobs;
|
||||
mThreadPool->run( [this, path = mUserSnippetsPath] {
|
||||
ScopedOp job( [] {}, [this] { --mSnippetJobs; } );
|
||||
Lock lock( mSnippetLoadMutex );
|
||||
if ( mShuttingDown )
|
||||
return;
|
||||
FileSystem::makeDir( path, true );
|
||||
loadSnippetDirectory( path, UserSnippetSource::User, true );
|
||||
} );
|
||||
setSnippetWorkspaceFolder( pluginManager->getWorkspaceFolder() );
|
||||
|
||||
subscribeFileSystemListener();
|
||||
mReady = true;
|
||||
fireReadyCbs();
|
||||
setReady( clock.getElapsedTime() );
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::loadSnippetDirectory( const std::string& path, UserSnippetSource source,
|
||||
bool languageFiles ) {
|
||||
if ( path.empty() || !FileSystem::isDirectory( path ) )
|
||||
return;
|
||||
for ( const auto& name : FileSystem::filesGetInPath( path, true, false, true ) ) {
|
||||
if ( mShuttingDown )
|
||||
return;
|
||||
const std::string filePath = path + name;
|
||||
if ( !FileSystem::isDirectory( filePath ) )
|
||||
loadSnippetFile( filePath, source, languageFiles );
|
||||
}
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::loadSnippetFile( const std::string& path, UserSnippetSource source,
|
||||
bool languageFiles ) {
|
||||
const std::string extension = FileSystem::fileExtension( path );
|
||||
if ( extension != "code-snippets" && ( !languageFiles || extension != "json" ) )
|
||||
return;
|
||||
std::string contents;
|
||||
if ( !FileSystem::fileGet( path, contents ) )
|
||||
return;
|
||||
std::string defaultScope;
|
||||
if ( extension == "json" )
|
||||
defaultScope = FileSystem::fileRemoveExtension( FileSystem::fileNameFromPath( path ) );
|
||||
std::vector<std::string> diagnostics;
|
||||
if ( !mUserSnippetStore.updateFile( contents, path, source, std::move( defaultScope ),
|
||||
&diagnostics ) ) {
|
||||
Log::warning( "AutoCompletePlugin: keeping the last valid snippets for invalid file %s",
|
||||
path.c_str() );
|
||||
}
|
||||
for ( const auto& diagnostic : diagnostics )
|
||||
Log::warning( "AutoCompletePlugin: %s", diagnostic.c_str() );
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::setSnippetWorkspaceFolder( std::string workspaceFolder ) {
|
||||
if ( !workspaceFolder.empty() )
|
||||
FileSystem::dirAddSlashAtEnd( workspaceFolder );
|
||||
Uint64 generation;
|
||||
{
|
||||
Lock lock( mSnippetLoadMutex );
|
||||
if ( mSnippetWorkspaceFolder == workspaceFolder )
|
||||
return;
|
||||
mSnippetWorkspaceFolder = workspaceFolder;
|
||||
mVSCodeSnippetsPath =
|
||||
workspaceFolder.empty() ? "" : workspaceFolder + ".vscode" + FileSystem::getOSSlash();
|
||||
mEcodeSnippetsPath =
|
||||
workspaceFolder.empty() ? "" : workspaceFolder + ".ecode" + FileSystem::getOSSlash();
|
||||
generation = ++mSnippetWorkspaceGeneration;
|
||||
}
|
||||
++mSnippetJobs;
|
||||
mThreadPool->run( [this, workspaceFolder = std::move( workspaceFolder ), generation] {
|
||||
ScopedOp job( [] {}, [this] { --mSnippetJobs; } );
|
||||
Lock lock( mSnippetLoadMutex );
|
||||
if ( mShuttingDown || generation != mSnippetWorkspaceGeneration )
|
||||
return;
|
||||
mUserSnippetStore.removeSource( UserSnippetSource::VSCodeProject );
|
||||
mUserSnippetStore.removeSource( UserSnippetSource::EcodeProject );
|
||||
if ( workspaceFolder.empty() )
|
||||
return;
|
||||
loadSnippetDirectory( workspaceFolder + ".vscode" + FileSystem::getOSSlash(),
|
||||
UserSnippetSource::VSCodeProject, false );
|
||||
loadSnippetDirectory( workspaceFolder + ".ecode" + FileSystem::getOSSlash(),
|
||||
UserSnippetSource::EcodeProject, false );
|
||||
} );
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::scheduleSnippetFileUpdate( std::string path, UserSnippetSource source,
|
||||
bool languageFiles, bool remove ) {
|
||||
const Uint64 generation = mSnippetWorkspaceGeneration;
|
||||
++mSnippetJobs;
|
||||
mThreadPool->run( [this, path = std::move( path ), source, languageFiles, remove, generation] {
|
||||
ScopedOp job( [] {}, [this] { --mSnippetJobs; } );
|
||||
Lock lock( mSnippetLoadMutex );
|
||||
if ( mShuttingDown ||
|
||||
( source != UserSnippetSource::User && generation != mSnippetWorkspaceGeneration ) )
|
||||
return;
|
||||
if ( remove )
|
||||
mUserSnippetStore.removeFile( path );
|
||||
else
|
||||
loadSnippetFile( path, source, languageFiles );
|
||||
} );
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::onLoadProject( const std::string& projectFolder,
|
||||
const std::string& /*projectStatePath*/ ) {
|
||||
setSnippetWorkspaceFolder( projectFolder );
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::onFileSystemEvent( const FileEvent& ev, const FileInfo& file ) {
|
||||
Plugin::onFileSystemEvent( ev, file );
|
||||
if ( mShuttingDown || isLoading() )
|
||||
return;
|
||||
|
||||
std::string_view path = file.getFilepath();
|
||||
UserSnippetSource source;
|
||||
bool languageFiles = false;
|
||||
bool isSnippetPath;
|
||||
const bool updatesSnippet =
|
||||
ev.type == FileSystemEventType::Delete || ev.type == FileSystemEventType::Add ||
|
||||
ev.type == FileSystemEventType::Modified || ev.type == FileSystemEventType::Moved;
|
||||
std::string scheduledPath;
|
||||
{
|
||||
Lock lock( mSnippetLoadMutex );
|
||||
if ( path.empty() ) {
|
||||
mSnippetEventPathBuffer.clear();
|
||||
mSnippetEventPathBuffer.reserve( ev.directory.size() + ev.filename.size() );
|
||||
mSnippetEventPathBuffer.append( ev.directory ).append( ev.filename );
|
||||
path = mSnippetEventPathBuffer;
|
||||
}
|
||||
isSnippetPath = getSnippetPathSource( path, mUserSnippetsPath, mVSCodeSnippetsPath,
|
||||
mEcodeSnippetsPath, source, languageFiles );
|
||||
if ( isSnippetPath && updatesSnippet )
|
||||
scheduledPath = path;
|
||||
}
|
||||
if ( isSnippetPath && updatesSnippet ) {
|
||||
if ( ev.type == FileSystemEventType::Delete )
|
||||
scheduleSnippetFileUpdate( std::move( scheduledPath ), source, languageFiles, true );
|
||||
else if ( ev.type == FileSystemEventType::Add || ev.type == FileSystemEventType::Modified ||
|
||||
ev.type == FileSystemEventType::Moved )
|
||||
scheduleSnippetFileUpdate( std::move( scheduledPath ), source, languageFiles, false );
|
||||
}
|
||||
|
||||
if ( ev.type == FileSystemEventType::Moved && !ev.oldFilename.empty() ) {
|
||||
std::string oldPath = ev.oldFilename;
|
||||
if ( FileSystem::isRelativePath( oldPath ) ) {
|
||||
std::string directory = ev.directory;
|
||||
FileSystem::dirAddSlashAtEnd( directory );
|
||||
oldPath = directory + oldPath;
|
||||
}
|
||||
{
|
||||
Lock lock( mSnippetLoadMutex );
|
||||
isSnippetPath = getSnippetPathSource( oldPath, mUserSnippetsPath, mVSCodeSnippetsPath,
|
||||
mEcodeSnippetsPath, source, languageFiles );
|
||||
}
|
||||
if ( isSnippetPath )
|
||||
scheduleSnippetFileUpdate( oldPath, source, languageFiles, true );
|
||||
}
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::onRegister( UICodeEditor* editor ) {
|
||||
Lock l( mDocMutex );
|
||||
std::vector<Uint32> listeners;
|
||||
@@ -659,6 +859,8 @@ void AutoCompletePlugin::requestCodeCompletion( UICodeEditor* editor ) {
|
||||
|
||||
bool AutoCompletePlugin::onTextInput( UICodeEditor* editor, const TextInputEvent& event ) {
|
||||
std::string partialSymbol( getPartialSymbol( &editor->getDocument() ) );
|
||||
const bool hasSnippetInput =
|
||||
mUserSnippetStore.size() > 0 && !getUserSnippetInput( editor ).empty();
|
||||
|
||||
auto lang = editor->getDocumentRef()->getSyntaxDefinition().getLSPName();
|
||||
auto cap = mCapabilities.find( lang );
|
||||
@@ -682,7 +884,7 @@ bool AutoCompletePlugin::onTextInput( UICodeEditor* editor, const TextInputEvent
|
||||
|
||||
if ( cap->second.completionProvider.provider ) {
|
||||
const auto& triggerCharacters = cap->second.completionProvider.triggerCharacters;
|
||||
if ( partialSymbol.size() >= 1 ||
|
||||
if ( partialSymbol.size() >= 1 || hasSnippetInput ||
|
||||
std::find( triggerCharacters.begin(), triggerCharacters.end(), event.getChar() ) !=
|
||||
triggerCharacters.end() ) {
|
||||
updateSuggestions( partialSymbol, editor );
|
||||
@@ -693,7 +895,7 @@ bool AutoCompletePlugin::onTextInput( UICodeEditor* editor, const TextInputEvent
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( partialSymbol.size() >= 3 ) {
|
||||
if ( partialSymbol.size() >= 3 || hasSnippetInput ) {
|
||||
updateSuggestions( partialSymbol, editor );
|
||||
} else {
|
||||
resetSuggestions( editor );
|
||||
@@ -781,6 +983,57 @@ static SnippetParser::VariableMap snippetVariables( TextDocument& doc,
|
||||
{ "TM_FILEPATH", filePath } };
|
||||
}
|
||||
|
||||
static std::string prepareSnippetText( TextDocument& doc, const TextRange& selection,
|
||||
std::string_view snippet ) {
|
||||
if ( snippet.find( '\n' ) == std::string_view::npos &&
|
||||
snippet.find( '\t' ) == std::string_view::npos )
|
||||
return std::string( snippet );
|
||||
const TextPosition position = selection.normalized().start();
|
||||
const TextPosition contentStart = doc.startOfContent( position );
|
||||
std::string baseIndent;
|
||||
if ( contentStart.column() > 0 )
|
||||
baseIndent =
|
||||
doc.line( position.line() ).getText().substr( 0, contentStart.column() ).toUtf8();
|
||||
const std::string indent = doc.getIndentString().toUtf8();
|
||||
std::string prepared;
|
||||
prepared.reserve( snippet.size() + baseIndent.size() * 2 );
|
||||
bool lineStart = true;
|
||||
for ( const char ch : snippet ) {
|
||||
if ( lineStart && ch == '\t' ) {
|
||||
prepared += indent;
|
||||
continue;
|
||||
}
|
||||
prepared.push_back( ch );
|
||||
lineStart = ch == '\n';
|
||||
if ( lineStart )
|
||||
prepared += baseIndent;
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
static TextRange userSnippetActivationRange( TextDocument& doc, const TextRange& selection,
|
||||
std::string_view prefix,
|
||||
std::string_view partialSymbol ) {
|
||||
if ( selection.hasSelection() )
|
||||
return selection;
|
||||
const TextPosition end = selection.start();
|
||||
const auto rangeFor = [&]( std::string_view text ) {
|
||||
return TextRange(
|
||||
doc.positionOffset( end, -static_cast<int>( String::utf8Length( text ) ) ), end );
|
||||
};
|
||||
if ( !prefix.empty() ) {
|
||||
const TextRange prefixRange = rangeFor( prefix );
|
||||
if ( doc.getText( prefixRange ).toUtf8() == prefix )
|
||||
return prefixRange;
|
||||
}
|
||||
if ( !partialSymbol.empty() ) {
|
||||
const TextRange symbolRange = rangeFor( partialSymbol );
|
||||
if ( doc.getText( symbolRange ).toUtf8() == partialSymbol )
|
||||
return symbolRange;
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::pickSuggestion( UICodeEditor* editor ) {
|
||||
if ( mSnippetChoiceSuggestions )
|
||||
return pickSnippetChoice( editor );
|
||||
@@ -806,13 +1059,19 @@ void AutoCompletePlugin::pickSuggestion( UICodeEditor* editor ) {
|
||||
} else {
|
||||
std::vector<SnippetInsertion> insertions;
|
||||
insertions.reserve( prevSels.size() );
|
||||
for ( const auto& selection : prevSels )
|
||||
for ( const auto& selection : prevSels ) {
|
||||
const std::string prepared = prepareSnippetText( *doc, selection, rawInsertText );
|
||||
insertions.push_back(
|
||||
{ SnippetParser::parse( rawInsertText, snippetVariables( *doc, selection ) ),
|
||||
{} } );
|
||||
{ SnippetParser::parse( prepared, snippetVariables( *doc, selection ) ), {} } );
|
||||
}
|
||||
|
||||
if ( prevSels.size() == 1 && suggestion.range.isValid() &&
|
||||
doc->isValidRange( suggestion.range ) ) {
|
||||
if ( suggestion.source == Suggestion::Source::UserSnippet ) {
|
||||
for ( size_t index = 0; index < prevSels.size(); ++index )
|
||||
doc->setSelection( index,
|
||||
userSnippetActivationRange( *doc, prevSels[index],
|
||||
suggestion.matchedPrefix, symbol ) );
|
||||
} else if ( prevSels.size() == 1 && suggestion.range.isValid() &&
|
||||
doc->isValidRange( suggestion.range ) ) {
|
||||
doc->setSelection( suggestion.range );
|
||||
} else if ( !symbol.empty() ) {
|
||||
doc->execute( "delete-to-previous-word" );
|
||||
@@ -1202,7 +1461,7 @@ AutoCompletePlugin::processCodeCompletion( const LSPCompletionList& completion )
|
||||
item.insertTextFormat } );
|
||||
}
|
||||
}
|
||||
if ( suggestions.empty() || !mSuggestionsEditor )
|
||||
if ( !mSuggestionsEditor )
|
||||
return {};
|
||||
UICodeEditor* editor = nullptr;
|
||||
{
|
||||
@@ -1212,6 +1471,8 @@ AutoCompletePlugin::processCodeCompletion( const LSPCompletionList& completion )
|
||||
if ( !editor )
|
||||
return {};
|
||||
std::string symbol( getPartialSymbol( editor->getDocumentRef().get() ) );
|
||||
SymbolsList userSnippets =
|
||||
getUserSnippetSuggestions( editor, symbol, eemax<size_t>( 100UL, suggestions.size() ) );
|
||||
const std::string& lang = editor->getDocument().getSyntaxDefinition().getLanguageName();
|
||||
bool hasLangSuggestions = false;
|
||||
{
|
||||
@@ -1219,15 +1480,20 @@ AutoCompletePlugin::processCodeCompletion( const LSPCompletionList& completion )
|
||||
auto langSuggestions = mLangCache.find( lang );
|
||||
hasLangSuggestions = langSuggestions != mLangCache.end();
|
||||
}
|
||||
if ( symbol.empty() || !hasLangSuggestions ) {
|
||||
if ( symbol.empty() ) {
|
||||
suggestions.insert( suggestions.end(), std::make_move_iterator( userSnippets.begin() ),
|
||||
std::make_move_iterator( userSnippets.end() ) );
|
||||
Lock l( mSuggestionsMutex );
|
||||
mSuggestions = suggestions;
|
||||
mSuggestions = std::move( suggestions );
|
||||
} else {
|
||||
SymbolsList fuzzySuggestions;
|
||||
{
|
||||
if ( hasLangSuggestions ) {
|
||||
Lock l2( mLangSymbolsMutex );
|
||||
auto& symbols = mLangCache[lang];
|
||||
fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &symbols }, symbol,
|
||||
fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &symbols, &userSnippets }, symbol,
|
||||
eemax<size_t>( 100UL, suggestions.size() ) );
|
||||
} else {
|
||||
fuzzySuggestions = fuzzyMatchSymbols( { &suggestions, &userSnippets }, symbol,
|
||||
eemax<size_t>( 100UL, suggestions.size() ) );
|
||||
}
|
||||
|
||||
@@ -1395,6 +1661,8 @@ void AutoCompletePlugin::updateShortcuts() {
|
||||
PluginRequestHandle AutoCompletePlugin::processResponse( const PluginMessage& msg ) {
|
||||
if ( msg.type == PluginMessageType::UIReady ) {
|
||||
updateShortcuts();
|
||||
} else if ( msg.type == PluginMessageType::WorkspaceFolderChanged ) {
|
||||
setSnippetWorkspaceFolder( msg.asJSON().value( "folder", "" ) );
|
||||
} else if ( msg.isResponse() && msg.type == PluginMessageType::CodeCompletion ) {
|
||||
if ( msg.responseID ) {
|
||||
Lock l( mHandlesMutex );
|
||||
@@ -1450,6 +1718,22 @@ std::string AutoCompletePlugin::getPartialSymbol( TextDocument* doc ) {
|
||||
return doc->getText( { start, end } ).toUtf8();
|
||||
}
|
||||
|
||||
std::string AutoCompletePlugin::getUserSnippetInput( UICodeEditor* editor ) const {
|
||||
if ( !editor || editor->getDocument().getSelection().hasSelection() )
|
||||
return {};
|
||||
TextDocument& doc = editor->getDocument();
|
||||
const TextPosition end = doc.getSelection().end();
|
||||
static constexpr size_t MAX_SNIPPET_INPUT_LENGTH = 128;
|
||||
const TextPosition start(
|
||||
end.line(),
|
||||
eemax<Int64>( 0, end.column() - static_cast<Int64>( MAX_SNIPPET_INPUT_LENGTH ) ) );
|
||||
std::string input = doc.getText( { start, end } ).toUtf8();
|
||||
const size_t whitespace = input.find_last_of( " \t" );
|
||||
if ( whitespace != std::string::npos )
|
||||
input.erase( 0, whitespace + 1 );
|
||||
return input;
|
||||
}
|
||||
|
||||
void AutoCompletePlugin::update( UICodeEditor* editor ) {
|
||||
for ( auto clientIt = mSnippetClients.begin(); clientIt != mSnippetClients.end(); ) {
|
||||
if ( !clientIt->second->isAttached() )
|
||||
@@ -2030,12 +2314,19 @@ void AutoCompletePlugin::runUpdateSuggestions( const std::string& symbol,
|
||||
}
|
||||
if ( tryRequestCapabilities( editor ) )
|
||||
requestCodeCompletion( editor );
|
||||
if ( symbol.empty() || symbols.empty() )
|
||||
return;
|
||||
SymbolsList userSnippets =
|
||||
getUserSnippetSuggestions( editor, symbol, mSuggestionsMaxVisible );
|
||||
|
||||
Lock l( fromDocCache ? mDocMutex : mLangSymbolsMutex );
|
||||
SymbolsList matches;
|
||||
if ( symbol.empty() ) {
|
||||
matches = std::move( userSnippets );
|
||||
} else {
|
||||
Lock l( fromDocCache ? mDocMutex : mLangSymbolsMutex );
|
||||
matches =
|
||||
fuzzyMatchSymbols( { &symbols, &userSnippets }, symbol, mSuggestionsMaxVisible );
|
||||
}
|
||||
Lock l2( mSuggestionsMutex );
|
||||
mSuggestions = fuzzyMatchSymbols( { &symbols }, symbol, mSuggestionsMaxVisible );
|
||||
mSuggestions = std::move( matches );
|
||||
}
|
||||
editor->runOnMainThread( [editor] { editor->invalidateDraw(); } );
|
||||
}
|
||||
@@ -2049,30 +2340,67 @@ void AutoCompletePlugin::updateSuggestions( const std::string& symbol, UICodeEdi
|
||||
usesOwnSymbols = mDocUsesOwnSymbols[&doc];
|
||||
}
|
||||
|
||||
bool scheduled = false;
|
||||
if ( usesOwnSymbols ) {
|
||||
Lock l( mDocMutex );
|
||||
auto docCache = mDocCache.find( &doc );
|
||||
if ( docCache == mDocCache.end() || mShuttingDown )
|
||||
return;
|
||||
const auto& symbols = docCache->second.symbols;
|
||||
{
|
||||
if ( docCache != mDocCache.end() && !mShuttingDown ) {
|
||||
const auto& symbols = docCache->second.symbols;
|
||||
mThreadPool->run( [this, symbol, &symbols, editor] {
|
||||
runUpdateSuggestions( symbol, symbols, editor, true );
|
||||
} );
|
||||
scheduled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string& lang = doc.getSyntaxDefinition().getLanguageName();
|
||||
Lock l( mLangSymbolsMutex );
|
||||
auto langSuggestions = mLangCache.find( lang );
|
||||
if ( langSuggestions == mLangCache.end() )
|
||||
return;
|
||||
const auto& symbols = langSuggestions->second;
|
||||
{
|
||||
mThreadPool->run( [this, symbol, &symbols, editor] {
|
||||
runUpdateSuggestions( symbol, symbols, editor, false );
|
||||
} );
|
||||
const std::string& lang = doc.getSyntaxDefinition().getLanguageName();
|
||||
Lock l( mLangSymbolsMutex );
|
||||
auto langSuggestions = mLangCache.find( lang );
|
||||
if ( langSuggestions != mLangCache.end() ) {
|
||||
const auto& symbols = langSuggestions->second;
|
||||
mThreadPool->run( [this, symbol, &symbols, editor] {
|
||||
runUpdateSuggestions( symbol, symbols, editor, false );
|
||||
} );
|
||||
scheduled = true;
|
||||
}
|
||||
}
|
||||
if ( !scheduled )
|
||||
mThreadPool->run( [this, symbol, editor] {
|
||||
runUpdateSuggestions( symbol, SymbolsList{}, editor, false );
|
||||
} );
|
||||
}
|
||||
|
||||
AutoCompletePlugin::SymbolsList
|
||||
AutoCompletePlugin::getUserSnippetSuggestions( UICodeEditor* editor, const std::string& symbol,
|
||||
size_t maxResults ) const {
|
||||
SymbolsList suggestions;
|
||||
if ( !editor )
|
||||
return suggestions;
|
||||
const auto& language = editor->getDocument().getSyntaxDefinition().getLSPName();
|
||||
std::string snippetInput = getUserSnippetInput( editor );
|
||||
if ( snippetInput.empty() )
|
||||
snippetInput = symbol;
|
||||
auto matches = mUserSnippetStore.find( language, snippetInput, maxResults );
|
||||
suggestions.reserve( matches.size() );
|
||||
for ( auto& match : matches ) {
|
||||
Suggestion suggestion( LSPCompletionItemKind::Snippet, std::move( match.matchedPrefix ),
|
||||
match.snippet.description.empty()
|
||||
? std::move( match.snippet.name )
|
||||
: match.snippet.name + " - " + match.snippet.description,
|
||||
{}, {}, std::move( match.snippet.body ), {},
|
||||
LSPInsertTextFormat::Snippet );
|
||||
suggestion.source = Suggestion::Source::UserSnippet;
|
||||
suggestion.matchedPrefix = std::move( match.matchedInput );
|
||||
suggestion.identityHash = hashCombine( String::hash( match.snippet.sourcePath ),
|
||||
String::hash( match.snippet.name ) );
|
||||
suggestion.score = match.score;
|
||||
suggestion.sourcePriority = match.snippet.source == UserSnippetSource::EcodeProject ? 2
|
||||
: match.snippet.source == UserSnippetSource::VSCodeProject ? 1
|
||||
: 0;
|
||||
suggestions.emplace_back( std::move( suggestion ) );
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
bool AutoCompletePlugin::onCreateContextMenu( UICodeEditor* editor, UIPopUpMenu* menu,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "../plugin.hpp"
|
||||
#include "../pluginmanager.hpp"
|
||||
#include "snippetparser.hpp"
|
||||
#include "usersnippetstore.hpp"
|
||||
#include <eepp/config.hpp>
|
||||
#include <eepp/system/clock.hpp>
|
||||
#include <eepp/system/mutex.hpp>
|
||||
@@ -22,6 +23,8 @@ class AutoCompletePlugin : public Plugin {
|
||||
public:
|
||||
class Suggestion {
|
||||
public:
|
||||
enum class Source { LocalSymbol, LSP, UserSnippet, SnippetChoice };
|
||||
|
||||
LSPCompletionItemKind kind{ LSPCompletionItemKind::Text };
|
||||
std::string text;
|
||||
std::string detail;
|
||||
@@ -30,7 +33,11 @@ class AutoCompletePlugin : public Plugin {
|
||||
std::string insertText;
|
||||
LSPInsertTextFormat insertTextFormat{ LSPInsertTextFormat::PlainText };
|
||||
double score{ 0 };
|
||||
int sourcePriority{ 0 };
|
||||
LSPMarkupContent documentation;
|
||||
size_t identityHash{ 0 };
|
||||
std::string matchedPrefix;
|
||||
Source source{ Source::LocalSymbol };
|
||||
|
||||
void setScore( const double& score ) const {
|
||||
const_cast<Suggestion*>( this )->score = score;
|
||||
@@ -48,11 +55,16 @@ class AutoCompletePlugin : public Plugin {
|
||||
range( range ),
|
||||
insertText( std::move( insertText ) ),
|
||||
insertTextFormat( insertTextFormat ),
|
||||
documentation( doc ) {};
|
||||
documentation( std::move( doc ) ),
|
||||
source( Source::LSP ) {};
|
||||
|
||||
bool operator<( const Suggestion& other ) const { return getCmpStr() < other.getCmpStr(); }
|
||||
|
||||
bool operator==( const Suggestion& other ) const { return text == other.text; }
|
||||
bool operator==( const Suggestion& other ) const {
|
||||
if ( source == Source::UserSnippet || other.source == Source::UserSnippet )
|
||||
return source == other.source && identityHash == other.identityHash;
|
||||
return text == other.text;
|
||||
}
|
||||
|
||||
protected:
|
||||
const std::string* getCmpStr() const { return !sortText.empty() ? &sortText : &text; }
|
||||
@@ -96,6 +108,9 @@ class AutoCompletePlugin : public Plugin {
|
||||
bool onMouseUp( UICodeEditor*, const Vector2i&, const Uint32& ) override;
|
||||
bool onMouseDoubleClick( UICodeEditor*, const Vector2i&, const Uint32& ) override;
|
||||
bool onMouseMove( UICodeEditor*, const Vector2i&, const Uint32& ) override;
|
||||
void onFileSystemEvent( const FileEvent&, const FileInfo& ) override;
|
||||
void onLoadProject( const std::string& projectFolder,
|
||||
const std::string& projectStatePath ) override;
|
||||
|
||||
const Rectf& getBoxPadding() const;
|
||||
|
||||
@@ -216,6 +231,15 @@ class AutoCompletePlugin : public Plugin {
|
||||
UnorderedMap<TextDocument*, std::unique_ptr<SnippetDocumentClient>> mSnippetClients;
|
||||
bool mChangingSnippetSelection{ false };
|
||||
bool mSnippetChoiceSuggestions{ false };
|
||||
UserSnippetStore mUserSnippetStore;
|
||||
Mutex mSnippetLoadMutex;
|
||||
std::string mUserSnippetsPath;
|
||||
std::string mSnippetWorkspaceFolder;
|
||||
std::string mVSCodeSnippetsPath;
|
||||
std::string mEcodeSnippetsPath;
|
||||
std::string mSnippetEventPathBuffer;
|
||||
std::atomic<Uint64> mSnippetWorkspaceGeneration{ 0 };
|
||||
std::atomic<Uint32> mSnippetJobs{ 0 };
|
||||
|
||||
explicit AutoCompletePlugin( PluginManager* pluginManager, bool sync );
|
||||
|
||||
@@ -234,6 +258,21 @@ class AutoCompletePlugin : public Plugin {
|
||||
void runUpdateSuggestions( const std::string& symbol, const SymbolsList& symbols,
|
||||
UICodeEditor* editor, bool fromDocCache );
|
||||
|
||||
SymbolsList getUserSnippetSuggestions( UICodeEditor* editor, const std::string& symbol,
|
||||
size_t maxResults ) const;
|
||||
|
||||
std::string getUserSnippetInput( UICodeEditor* editor ) const;
|
||||
|
||||
void loadSnippetDirectory( const std::string& path, UserSnippetSource source,
|
||||
bool languageFiles );
|
||||
|
||||
void loadSnippetFile( const std::string& path, UserSnippetSource source, bool languageFiles );
|
||||
|
||||
void setSnippetWorkspaceFolder( std::string workspaceFolder );
|
||||
|
||||
void scheduleSnippetFileUpdate( std::string path, UserSnippetSource source, bool languageFiles,
|
||||
bool remove );
|
||||
|
||||
void updateLangCache( const std::string& langName );
|
||||
|
||||
void pickSuggestion( UICodeEditor* editor );
|
||||
|
||||
328
src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp
Normal file
328
src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp
Normal file
@@ -0,0 +1,328 @@
|
||||
#include "usersnippetstore.hpp"
|
||||
#include "../../jsonhelper.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <eepp/core/string.hpp>
|
||||
#include <limits>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace ecode {
|
||||
|
||||
namespace {
|
||||
|
||||
static std::string normalizeScope( std::string scope ) {
|
||||
String::trimInPlace( scope );
|
||||
String::toLowerInPlace( scope );
|
||||
return scope;
|
||||
}
|
||||
|
||||
static SmallVector<std::string, 2> parseScopes( const json& definition, std::string defaultScope ) {
|
||||
SmallVector<std::string, 2> scopes;
|
||||
if ( !defaultScope.empty() ) {
|
||||
defaultScope = normalizeScope( std::move( defaultScope ) );
|
||||
if ( !defaultScope.empty() )
|
||||
scopes.emplace_back( std::move( defaultScope ) );
|
||||
return scopes;
|
||||
}
|
||||
if ( !definition.contains( "scope" ) || !definition["scope"].is_string() )
|
||||
return scopes;
|
||||
for ( auto& scope : String::split( definition["scope"].get<std::string>(), ',' ) ) {
|
||||
auto normalized = normalizeScope( std::move( scope ) );
|
||||
if ( !normalized.empty() &&
|
||||
std::find( scopes.begin(), scopes.end(), normalized ) == scopes.end() )
|
||||
scopes.emplace_back( std::move( normalized ) );
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
static bool parseStringList( const json& value, SmallVector<std::string, 2>& strings,
|
||||
bool rejectEmpty ) {
|
||||
if ( value.is_string() ) {
|
||||
auto string = value.get<std::string>();
|
||||
if ( rejectEmpty && string.empty() )
|
||||
return false;
|
||||
strings.emplace_back( std::move( string ) );
|
||||
return true;
|
||||
}
|
||||
if ( !value.is_array() )
|
||||
return false;
|
||||
for ( const auto& item : value ) {
|
||||
if ( !item.is_string() )
|
||||
return false;
|
||||
auto string = item.get<std::string>();
|
||||
if ( rejectEmpty && string.empty() )
|
||||
continue;
|
||||
if ( std::find( strings.begin(), strings.end(), string ) == strings.end() )
|
||||
strings.emplace_back( std::move( string ) );
|
||||
}
|
||||
return !strings.empty() || !rejectEmpty;
|
||||
}
|
||||
|
||||
static bool parseBody( const json& value, std::string& body ) {
|
||||
if ( value.is_string() ) {
|
||||
body = value.get<std::string>();
|
||||
return true;
|
||||
}
|
||||
if ( !value.is_array() )
|
||||
return false;
|
||||
bool first = true;
|
||||
for ( const auto& line : value ) {
|
||||
if ( !line.is_string() )
|
||||
return false;
|
||||
if ( !first )
|
||||
body += '\n';
|
||||
body += line.get_ref<const std::string&>();
|
||||
first = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int sourcePriority( UserSnippetSource source ) {
|
||||
switch ( source ) {
|
||||
case UserSnippetSource::EcodeProject:
|
||||
return 2;
|
||||
case UserSnippetSource::VSCodeProject:
|
||||
return 1;
|
||||
case UserSnippetSource::User:
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UserSnippetParseResult UserSnippetStore::parseFile( std::string_view contents,
|
||||
std::string sourcePath,
|
||||
UserSnippetSource source,
|
||||
std::string defaultScope ) {
|
||||
UserSnippetParseResult result;
|
||||
const std::string sanitized = json_strip_trailing_commas( contents );
|
||||
json root = json::parse( sanitized, nullptr, false, true );
|
||||
if ( root.is_discarded() || !root.is_object() ) {
|
||||
result.diagnostics.emplace_back( sourcePath + ": invalid JSONC root" );
|
||||
return result;
|
||||
}
|
||||
result.valid = true;
|
||||
result.snippets.reserve( root.size() );
|
||||
for ( const auto& [name, value] : root.items() ) {
|
||||
if ( name == "$schema" && value.is_string() )
|
||||
continue;
|
||||
if ( !value.is_object() ) {
|
||||
result.diagnostics.emplace_back( sourcePath + ": snippet '" + name +
|
||||
"' must be an object" );
|
||||
continue;
|
||||
}
|
||||
if ( !value.contains( "prefix" ) || !value.contains( "body" ) ) {
|
||||
result.diagnostics.emplace_back( sourcePath + ": snippet '" + name +
|
||||
"' requires prefix and body" );
|
||||
continue;
|
||||
}
|
||||
UserSnippetDefinition snippet;
|
||||
snippet.name = name;
|
||||
snippet.sourcePath = sourcePath;
|
||||
snippet.source = source;
|
||||
if ( !parseStringList( value["prefix"], snippet.prefixes, true ) ||
|
||||
!parseBody( value["body"], snippet.body ) ) {
|
||||
result.diagnostics.emplace_back( sourcePath + ": snippet '" + name +
|
||||
"' has an invalid prefix or body" );
|
||||
continue;
|
||||
}
|
||||
if ( value.contains( "description" ) && value["description"].is_string() )
|
||||
snippet.description = value["description"].get<std::string>();
|
||||
if ( value.contains( "scope" ) && !value["scope"].is_string() && defaultScope.empty() ) {
|
||||
result.diagnostics.emplace_back( sourcePath + ": snippet '" + name +
|
||||
"' has an invalid scope" );
|
||||
continue;
|
||||
}
|
||||
snippet.scopes = parseScopes( value, defaultScope );
|
||||
result.snippets.emplace_back( std::move( snippet ) );
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool UserSnippetStore::updateFile( std::string_view contents, std::string sourcePath,
|
||||
UserSnippetSource source, std::string defaultScope,
|
||||
std::vector<std::string>* diagnostics ) {
|
||||
const String::HashType hash = String::hash( contents );
|
||||
{
|
||||
Lock lock( mMutex );
|
||||
auto found = mFiles.find( sourcePath );
|
||||
if ( found != mFiles.end() && found->second.hash == hash ) {
|
||||
if ( diagnostics )
|
||||
diagnostics->clear();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
auto parsed = parseFile( contents, sourcePath, source, std::move( defaultScope ) );
|
||||
if ( diagnostics )
|
||||
*diagnostics = std::move( parsed.diagnostics );
|
||||
if ( !parsed.valid )
|
||||
return false;
|
||||
Lock lock( mMutex );
|
||||
mFiles[sourcePath] = { source, hash, std::move( parsed.snippets ) };
|
||||
rebuildSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UserSnippetStore::removeFile( std::string_view sourcePath ) {
|
||||
Lock lock( mMutex );
|
||||
auto found = mFiles.find( std::string( sourcePath ) );
|
||||
if ( found == mFiles.end() )
|
||||
return false;
|
||||
mFiles.erase( found );
|
||||
rebuildSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
void UserSnippetStore::removeSource( UserSnippetSource source ) {
|
||||
Lock lock( mMutex );
|
||||
bool changed = false;
|
||||
for ( auto it = mFiles.begin(); it != mFiles.end(); ) {
|
||||
if ( it->second.source == source ) {
|
||||
it = mFiles.erase( it );
|
||||
changed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if ( changed )
|
||||
rebuildSnapshot();
|
||||
}
|
||||
|
||||
void UserSnippetStore::clear() {
|
||||
Lock lock( mMutex );
|
||||
mFiles.clear();
|
||||
mSnapshot = std::make_shared<Snapshot>();
|
||||
}
|
||||
|
||||
void UserSnippetStore::rebuildSnapshot() {
|
||||
auto snapshot = std::make_shared<Snapshot>();
|
||||
size_t count = 0;
|
||||
for ( const auto& file : mFiles )
|
||||
count += file.second.snippets.size();
|
||||
snapshot->snippets.reserve( count );
|
||||
for ( const auto& file : mFiles ) {
|
||||
for ( const auto& snippet : file.second.snippets ) {
|
||||
const size_t index = snapshot->snippets.size();
|
||||
snapshot->snippets.emplace_back( snippet );
|
||||
if ( snippet.scopes.empty() ) {
|
||||
snapshot->global.emplace_back( index );
|
||||
} else {
|
||||
for ( const auto& scope : snippet.scopes )
|
||||
snapshot->byLanguage[scope].emplace_back( index );
|
||||
}
|
||||
}
|
||||
}
|
||||
mSnapshot = std::move( snapshot );
|
||||
}
|
||||
|
||||
std::vector<UserSnippetMatch> UserSnippetStore::find( std::string_view language,
|
||||
std::string_view pattern,
|
||||
size_t maxResults ) const {
|
||||
if ( maxResults == 0 )
|
||||
return {};
|
||||
std::shared_ptr<const Snapshot> snapshot;
|
||||
{
|
||||
Lock lock( mMutex );
|
||||
snapshot = mSnapshot;
|
||||
}
|
||||
std::string normalizedLanguage( language );
|
||||
String::toLowerInPlace( normalizedLanguage );
|
||||
std::vector<size_t> candidates;
|
||||
candidates.reserve( snapshot->global.size() + 32 );
|
||||
candidates.insert( candidates.end(), snapshot->global.begin(), snapshot->global.end() );
|
||||
auto languageIt = snapshot->byLanguage.find( normalizedLanguage );
|
||||
if ( languageIt != snapshot->byLanguage.end() )
|
||||
candidates.insert( candidates.end(), languageIt->second.begin(), languageIt->second.end() );
|
||||
|
||||
SmallVector<std::string, 16> inputs;
|
||||
if ( !pattern.empty() ) {
|
||||
for ( size_t offset = 0; offset < pattern.size(); ++offset ) {
|
||||
const Uint8 current = static_cast<Uint8>( pattern[offset] );
|
||||
if ( offset > 0 && ( current & 0xC0 ) == 0x80 )
|
||||
continue;
|
||||
if ( offset > 0 ) {
|
||||
const Uint8 previous = static_cast<Uint8>( pattern[offset - 1] );
|
||||
if ( previous >= 0x80 || std::isalnum( previous ) || previous == '_' )
|
||||
continue;
|
||||
}
|
||||
inputs.emplace_back( pattern.substr( offset ) );
|
||||
}
|
||||
}
|
||||
static constexpr size_t NO_INPUT = std::numeric_limits<size_t>::max();
|
||||
struct Candidate {
|
||||
size_t snippetIndex;
|
||||
size_t prefixIndex;
|
||||
size_t inputIndex;
|
||||
int score;
|
||||
};
|
||||
std::vector<Candidate> matchedCandidates;
|
||||
matchedCandidates.reserve( eemin( maxResults, candidates.size() ) );
|
||||
for ( size_t index : candidates ) {
|
||||
const auto& snippet = snapshot->snippets[index];
|
||||
int bestScore = std::numeric_limits<int>::min();
|
||||
size_t bestPrefix = NO_INPUT;
|
||||
size_t bestInput = NO_INPUT;
|
||||
for ( size_t prefixIndex = 0; prefixIndex < snippet.prefixes.size(); ++prefixIndex ) {
|
||||
const auto& prefix = snippet.prefixes[prefixIndex];
|
||||
if ( pattern.empty() ) {
|
||||
if ( bestPrefix == NO_INPUT ) {
|
||||
bestScore = 0;
|
||||
bestPrefix = prefixIndex;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for ( size_t inputIndex = 0; inputIndex < inputs.size(); ++inputIndex ) {
|
||||
const auto& input = inputs[inputIndex];
|
||||
const int score = String::fuzzyMatchSimple( input, prefix, false, true );
|
||||
if ( score <= 0 )
|
||||
continue;
|
||||
const int weightedScore =
|
||||
score + static_cast<int>( String::utf8Length( input ) * 1000 );
|
||||
if ( weightedScore > bestScore ) {
|
||||
bestScore = weightedScore;
|
||||
bestPrefix = prefixIndex;
|
||||
bestInput = inputIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( bestPrefix == NO_INPUT || ( !pattern.empty() && bestScore <= 0 ) )
|
||||
continue;
|
||||
matchedCandidates.push_back( { index, bestPrefix, bestInput, bestScore } );
|
||||
}
|
||||
std::sort( matchedCandidates.begin(), matchedCandidates.end(),
|
||||
[&]( const auto& left, const auto& right ) {
|
||||
if ( left.score != right.score )
|
||||
return left.score > right.score;
|
||||
const auto& leftSnippet = snapshot->snippets[left.snippetIndex];
|
||||
const auto& rightSnippet = snapshot->snippets[right.snippetIndex];
|
||||
const int leftPriority = sourcePriority( leftSnippet.source );
|
||||
const int rightPriority = sourcePriority( rightSnippet.source );
|
||||
if ( leftPriority != rightPriority )
|
||||
return leftPriority > rightPriority;
|
||||
return leftSnippet.name < rightSnippet.name;
|
||||
} );
|
||||
if ( matchedCandidates.size() > maxResults )
|
||||
matchedCandidates.resize( maxResults );
|
||||
std::vector<UserSnippetMatch> matches;
|
||||
matches.reserve( matchedCandidates.size() );
|
||||
for ( const auto& candidate : matchedCandidates ) {
|
||||
const auto& snippet = snapshot->snippets[candidate.snippetIndex];
|
||||
matches.push_back(
|
||||
{ snippet, snippet.prefixes[candidate.prefixIndex],
|
||||
candidate.inputIndex != NO_INPUT ? inputs[candidate.inputIndex] : std::string{},
|
||||
candidate.score } );
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
size_t UserSnippetStore::size() const {
|
||||
Lock lock( mMutex );
|
||||
return mSnapshot->snippets.size();
|
||||
}
|
||||
|
||||
} // namespace ecode
|
||||
87
src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp
Normal file
87
src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp
Normal file
@@ -0,0 +1,87 @@
|
||||
#ifndef ECODE_USERSNIPPETSTORE_HPP
|
||||
#define ECODE_USERSNIPPETSTORE_HPP
|
||||
|
||||
#include <eepp/core/containers.hpp>
|
||||
#include <eepp/core/small_vector.hpp>
|
||||
#include <eepp/core/string.hpp>
|
||||
#include <eepp/system/lock.hpp>
|
||||
#include <eepp/system/mutex.hpp>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
using namespace EE;
|
||||
using namespace EE::System;
|
||||
|
||||
namespace ecode {
|
||||
|
||||
enum class UserSnippetSource { User, VSCodeProject, EcodeProject };
|
||||
|
||||
struct UserSnippetDefinition {
|
||||
std::string name;
|
||||
SmallVector<std::string, 2> prefixes;
|
||||
std::string body;
|
||||
std::string description;
|
||||
SmallVector<std::string, 2> scopes;
|
||||
std::string sourcePath;
|
||||
UserSnippetSource source{ UserSnippetSource::User };
|
||||
};
|
||||
|
||||
struct UserSnippetParseResult {
|
||||
std::vector<UserSnippetDefinition> snippets;
|
||||
std::vector<std::string> diagnostics;
|
||||
bool valid{ false };
|
||||
};
|
||||
|
||||
struct UserSnippetMatch {
|
||||
UserSnippetDefinition snippet;
|
||||
std::string matchedPrefix;
|
||||
std::string matchedInput;
|
||||
int score{ 0 };
|
||||
};
|
||||
|
||||
class UserSnippetStore {
|
||||
public:
|
||||
static UserSnippetParseResult parseFile( std::string_view contents, std::string sourcePath,
|
||||
UserSnippetSource source,
|
||||
std::string defaultScope = {} );
|
||||
|
||||
bool updateFile( std::string_view contents, std::string sourcePath, UserSnippetSource source,
|
||||
std::string defaultScope = {},
|
||||
std::vector<std::string>* diagnostics = nullptr );
|
||||
|
||||
bool removeFile( std::string_view sourcePath );
|
||||
|
||||
void removeSource( UserSnippetSource source );
|
||||
|
||||
void clear();
|
||||
|
||||
std::vector<UserSnippetMatch> find( std::string_view language, std::string_view pattern,
|
||||
size_t maxResults ) const;
|
||||
|
||||
size_t size() const;
|
||||
|
||||
private:
|
||||
struct SourceFile {
|
||||
UserSnippetSource source{ UserSnippetSource::User };
|
||||
String::HashType hash{ 0 };
|
||||
std::vector<UserSnippetDefinition> snippets;
|
||||
};
|
||||
|
||||
struct Snapshot {
|
||||
std::vector<UserSnippetDefinition> snippets;
|
||||
std::vector<size_t> global;
|
||||
UnorderedMap<std::string, std::vector<size_t>> byLanguage;
|
||||
};
|
||||
|
||||
mutable Mutex mMutex;
|
||||
UnorderedMap<std::string, SourceFile> mFiles;
|
||||
std::shared_ptr<const Snapshot> mSnapshot{ std::make_shared<Snapshot>() };
|
||||
|
||||
void rebuildSnapshot();
|
||||
};
|
||||
|
||||
} // namespace ecode
|
||||
|
||||
#endif // ECODE_USERSNIPPETSTORE_HPP
|
||||
@@ -107,6 +107,10 @@ const std::string& PluginManager::getPluginsPath() const {
|
||||
return mPluginsPath;
|
||||
}
|
||||
|
||||
const std::string& PluginManager::getConfigPath() const {
|
||||
return mConfigPath;
|
||||
}
|
||||
|
||||
const std::map<std::string, bool>& PluginManager::getPluginsEnabled() const {
|
||||
return mPluginsEnabled;
|
||||
}
|
||||
|
||||
@@ -298,6 +298,8 @@ class PluginManager {
|
||||
|
||||
const std::string& getPluginsPath() const;
|
||||
|
||||
const std::string& getConfigPath() const;
|
||||
|
||||
const std::map<std::string, bool>& getPluginsEnabled() const;
|
||||
|
||||
void onNewEditor( UICodeEditor* editor );
|
||||
|
||||
Reference in New Issue
Block a user