From 49a65743681a8556a58b338a29304dcf58110bbb Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:07:49 +0200 Subject: [PATCH 01/15] Honor continueAnalysisOnError when building the workspace cache updateCache() calls buildReferences() with no argument for every script in the workspace, and both native entry points hardcoded their fallback to `true`, so the setting was ignored on the one path that compiles everything. That matters because continuing past errors segfaults the compiler on a `local` declaration inside a `case` branch: case (x) 1: local y := 1; endcase On a 1240-script workspace the cache build died at file 47 after 0.6s; it now completes in 9.1s. The compiler bug itself is unfixed and still reachable on the default setting. --- native/cpp/napi/LSPDocument.cc | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/native/cpp/napi/LSPDocument.cc b/native/cpp/napi/LSPDocument.cc index d062575..070c57c 100644 --- a/native/cpp/napi/LSPDocument.cc +++ b/native/cpp/napi/LSPDocument.cc @@ -154,8 +154,14 @@ Napi::Value LSPDocument::Analyze( const Napi::CallbackInfo& info ) compiler->set_include_compile_mode(); } - bool continue_on_error = - info.Length() > 0 && info[0].IsBoolean() ? info[0].As().Value() : true; + // Falls back to the configured setting rather than hardcoding `true`. + // buildReferences() is called with no argument for every script in the + // workspace cache build, so a hardcoded default silently overrode a user + // who had set escript.continueAnalysisOnError to false precisely to avoid + // the compiler crashes that option is documented to cause. + bool continue_on_error = info.Length() > 0 && info[0].IsBoolean() + ? info[0].As().Value() + : gExtensionConfiguration.continueAnalysisOnError; compiler_workspace = compiler->analyze( pathname_, *report, type == LSPDocumentType::EM, continue_on_error ); @@ -606,8 +612,14 @@ Napi::Value LSPDocument::BuildReferences( const Napi::CallbackInfo& info ) compiler->set_include_compile_mode(); } - bool continue_on_error = - info.Length() > 0 && info[0].IsBoolean() ? info[0].As().Value() : true; + // Falls back to the configured setting rather than hardcoding `true`. + // buildReferences() is called with no argument for every script in the + // workspace cache build, so a hardcoded default silently overrode a user + // who had set escript.continueAnalysisOnError to false precisely to avoid + // the compiler crashes that option is documented to cause. + bool continue_on_error = info.Length() > 0 && info[0].IsBoolean() + ? info[0].As().Value() + : gExtensionConfiguration.continueAnalysisOnError; if ( auto local_compiler_workspace = compiler->analyze( pathname_, *local_report, type == LSPDocumentType::EM, continue_on_error ) ) From c0958e27447c78616c91675568d08af07dbfe866 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:07:49 +0200 Subject: [PATCH 02/15] Report native failures instead of dying silently - Define NODE_ADDON_API_CPP_EXCEPTIONS_ALL. Without it only Napi::Error was converted to a JS exception; any other std::exception escaping an N-API method reached std::terminate() and aborted with no message. - Add uncaughtException/unhandledRejection handlers, Node diagnostic reports, and a breadcrumb naming the in-flight request. server/connection is required after they are installed, since a static import would be hoisted above the native addon load it is meant to cover. - Fix XmlDocParser testing paramValue twice and never paramName, which constructed a std::string from nullptr on a with no name. - Emit a PDB for Release builds, archive it in CI, ship only the .node. - Log stacks instead of ${ex} or e.message. - Add escript.trace.server; correct the output channel name in README. - Re-push configuration only when an escript setting changed. --- .github/workflows/build.yml | 9 +++ .vscodeignore | 2 +- README.md | 2 +- client/src/activatePolDebug.ts | 4 +- client/src/extension.ts | 6 ++ native/CMakeLists.txt | 16 +++++ native/cpp/misc/XmlDocParser.cc | 2 +- package.json | 10 +++ server/src/index.ts | 19 ++++- server/src/misc/Diagnostics.ts | 120 ++++++++++++++++++++++++++++++++ 10 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 server/src/misc/Diagnostics.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3426e63..5766a41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,6 +84,15 @@ jobs: name: vscode-escript-native.win32-x64 path: native/build/Release/vscode-escript-native.win32-x64.node + # Kept out of the .vsix (see .vscodeignore) but retained here so a crash + # dump from a released build can be resolved to a function and line. + - name: Upload Debug Symbols + uses: actions/upload-artifact@v4 + with: + name: vscode-escript-native.win32-x64.symbols + path: native/build/Release/vscode-escript-native.win32-x64.pdb + if-no-files-found: warn + mac: name: Build and Test [Mac] runs-on: macos-latest diff --git a/.vscodeignore b/.vscodeignore index c9ebedf..7b97207 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -25,6 +25,6 @@ native/CMakeLists.txt native/cpp/** native/prebuilds/** native/build/** -!native/build/Release/** +!native/build/Release/*.node native/polserver/** native/node_modules/** diff --git a/README.md b/README.md index 2d02ab6..bc36f42 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ https://github.com/polserver/vscode-escript/issues. - Run the launch config. - In the new Visual Studio Code [Extension Development Host], open a POL distro folder that contains `pol.cfg` and `scripts/ecompile.cfg`. -- Check the "ECompile Language Server" channel in the Output pane. +- Check the "EScript Language Server" channel in the Output pane. ## NVIM LSP integration Run `npm install && npm build` in this folder. This installs all necessary diff --git a/client/src/activatePolDebug.ts b/client/src/activatePolDebug.ts index df1c4ab..36b0d96 100644 --- a/client/src/activatePolDebug.ts +++ b/client/src/activatePolDebug.ts @@ -116,7 +116,9 @@ class PolDebugConfigurationProvider implements vscode.DebugConfigurationProvider config.pid = selectionPid; } } catch (e) { - vscode.window.showErrorMessage(`Could not get process list from debug server: ${e.message}`).then(_ => { }); + const detail = e instanceof Error ? (e.stack ?? e.message) : String(e); + console.error(`Could not get process list from debug server: ${detail}`); + vscode.window.showErrorMessage(`Could not get process list from debug server: ${e instanceof Error ? e.message : String(e)}`).then(_ => { }); return undefined; } finally { try { diff --git a/client/src/extension.ts b/client/src/extension.ts index f248018..b84bf23 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -66,6 +66,12 @@ export function activate(context: ExtensionContext) { activatePolDebug(context); workspace.onDidChangeConfiguration(e => { + // Without this check every settings change in the window -- including + // unrelated extensions' -- pushes a config notification, which can + // restart the workspace cache build. + if (!e.affectsConfiguration('escript')) { + return; + } client.sendNotification('didChangeConfiguration', { configuration: workspace.getConfiguration('escript') }); diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 8aabb82..abeaa92 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -53,6 +53,10 @@ target_link_libraries(${PROJECT_NAME} PUBLIC ) target_compile_definitions(${PROJECT_NAME} PRIVATE NAPI_VERSION=8 + # Without this, node-addon-api's callback wrapper catches only Napi::Error. + # Any other std::exception escaping an N-API method reaches std::terminate() + # and aborts the language server with no message, stack, or log entry. + NODE_ADDON_API_CPP_EXCEPTIONS_ALL ) if(MSVC) @@ -60,6 +64,18 @@ if(MSVC) $<$:/MT> #---------| $<$:/MTd> #---|-- Statically link the runtime libraries $<$:/MT> #--| + + # Emit debug symbols for optimized builds as well. Without a PDB, a crash + # dump from a released extension cannot be resolved to a function or line, + # which is why native aborts have been unattributable. + $<$:/Zi> + ) + target_link_options(${PROJECT_NAME} PRIVATE + # /DEBUG produces the PDB but disables these two by default; re-enabling + # them keeps the shipped binary equivalent to a plain Release build. + $<$:/DEBUG> + $<$:/OPT:REF> + $<$:/OPT:ICF> ) endif() diff --git a/native/cpp/misc/XmlDocParser.cc b/native/cpp/misc/XmlDocParser.cc index b0689f1..731aca2 100644 --- a/native/cpp/misc/XmlDocParser.cc +++ b/native/cpp/misc/XmlDocParser.cc @@ -70,7 +70,7 @@ std::unique_ptr XmlDocParser::parse_function( const std::string& f { auto* paramName = child->Attribute( "name" ); auto* paramValue = child->Attribute( "value" ); - if ( paramValue != nullptr && paramValue != nullptr ) + if ( paramName != nullptr && paramValue != nullptr ) { parsed->parameters.push_back( XmlDocFunctionParameter{ paramName, paramValue } ); } diff --git a/package.json b/package.json index cebe155..c8699ff 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,16 @@ "type": "boolean", "default": false, "markdownDescription": "By default, the compiler will only include functions that have been called when analyzing sources. If `true`, all functions will be analyzed, regardless if they are used." + }, + "escript.trace.server": { + "type": "string", + "enum": [ + "off", + "messages", + "verbose" + ], + "default": "off", + "description": "Traces the communication between VS Code and the EScript language server. Set to 'verbose' to record which request the server was handling when a problem occurred." } } }, diff --git a/server/src/index.ts b/server/src/index.ts index c481fb7..c6c7d9d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,4 +1,4 @@ -import { LSPServer } from './server/connection'; +import { installCrashHandlers, formatError } from './misc/Diagnostics'; import { parseArgs } from 'node:util'; @@ -19,5 +19,20 @@ const options = { storageFsPath: URI.parse(String(storageUri)).fsPath }; +installCrashHandlers(options.storageFsPath); + console.log(`Escript Language Server started [pid ${process.pid}]`); -new LSPServer(options).listen(); + +// `./server/connection` loads the native addon at its top level, and a static +// `import` of it would be hoisted above installCrashHandlers() -- leaving the +// single most likely startup failure completely unreported. Requiring it here +// keeps the ordering real. +try { + const { LSPServer } = require('./server/connection') as typeof import('./server/connection'); + new LSPServer(options).listen(); +} catch (e) { + process.stderr.write( + `[escript-lsp] Failed to start the language server: ${formatError(e)}\n` + ); + process.exit(1); +} diff --git a/server/src/misc/Diagnostics.ts b/server/src/misc/Diagnostics.ts new file mode 100644 index 0000000..d39865f --- /dev/null +++ b/server/src/misc/Diagnostics.ts @@ -0,0 +1,120 @@ +/** + * Crash attribution helpers. + * + * The compiler runs synchronously on the server's only thread, so a native + * failure takes the whole process down before any handler can report it. What + * survives is whatever was written before the call started -- hence the + * breadcrumb: the operation and document in flight are recorded up front, and + * the crash handlers below print them on the way out. + */ + +export type BreadcrumbPosition = { + line: number; + character: number; +}; + +type Breadcrumb = { + operation: string; + fsPath?: string; + position?: BreadcrumbPosition; + startedAt: number; +}; + +/** + * At most one native call is ever in flight, so a single slot suffices. + */ +let current: Breadcrumb | undefined; + +/** + * Retained after completion so a crash that happens just *after* a native call + * (during marshalling, or in a later microtask) still names the likely culprit. + */ +let previous: Breadcrumb | undefined; + +export function beginOperation(operation: string, fsPath?: string, position?: BreadcrumbPosition): void { + current = { operation, fsPath, position, startedAt: Date.now() }; +} + +export function endOperation(): void { + if (current) { + previous = current; + current = undefined; + } +} + +function describe(breadcrumb: Breadcrumb | undefined, label: string): string | undefined { + if (!breadcrumb) { + return undefined; + } + const { operation, fsPath, position, startedAt } = breadcrumb; + const where = fsPath ? ` ${fsPath}` : ''; + const at = position ? `:${position.line}:${position.character}` : ''; + return `${label}: ${operation}${where}${at} (${Date.now() - startedAt}ms ago)`; +} + +/** + * Human-readable description of what the server was doing, for crash reports. + */ +export function describeBreadcrumbs(): string { + const parts = [ + describe(current, 'in flight'), + describe(previous, 'previous') + ].filter((part): part is string => typeof part === 'string'); + + return parts.length ? parts.join('; ') : 'no operation recorded'; +} + +/** + * Renders an unknown thrown value with its stack. Several call sites previously + * interpolated errors into template strings, which calls `toString()` and drops + * the stack -- exactly the information a crash report needs. + */ +export function formatError(e: unknown): string { + if (e instanceof Error) { + return e.stack ?? `${e.name}: ${e.message}`; + } + return String(e); +} + +/** + * Installs last-resort handlers. Without these, an unhandled rejection kills the + * server silently on Node 15+, and a fatal error produces no diagnostic at all. + * + * Writes go straight to stderr rather than through the LSP connection: by the + * time these run the connection may already be gone, and the client pipes the + * server's stderr into its output channel regardless. + */ +export function installCrashHandlers(storageFsPath: string): void { + const report = (kind: string, error: unknown) => { + process.stderr.write( + `[escript-lsp] ${kind}\n` + + `[escript-lsp] ${describeBreadcrumbs()}\n` + + `[escript-lsp] ${formatError(error)}\n` + ); + }; + + process.on('uncaughtException', (error) => { + report('Uncaught exception; the language server will exit.', error); + process.exit(1); + }); + + process.on('unhandledRejection', (reason) => { + // Reported rather than rethrown: an unhandled rejection in a background + // task (such as the workspace cache build) should not take down a server + // that is otherwise serving requests fine. + report('Unhandled promise rejection.', reason); + }); + + // Node's diagnostic report captures a *native* stack on fatal errors, which + // is the only way to see where an abort inside the addon came from. + try { + process.report.directory = storageFsPath; + process.report.filename = ''; + process.report.reportOnFatalError = true; + process.stderr.write( + `[escript-lsp] Fatal-error diagnostic reports will be written to ${storageFsPath}\n` + ); + } catch (e) { + process.stderr.write(`[escript-lsp] Could not enable diagnostic reports: ${formatError(e)}\n`); + } +} From 86f53e57a3fac7d3d272d8711fcb88c6763e7f1e Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:07:49 +0200 Subject: [PATCH 03/15] Cache include parse trees, analyze lazily, guard the N-API calls - Configure and prune the em/inc parse tree caches. They were constructed but never configure()d, and load() bypasses the cache while keep == 0, so every analyze() reparsed the whole include chain. 9.2ms -> 1.6ms per analyze on a two-include fixture. Cleared when an .em/.inc changes, since get_contents() serves unsaved editor text. - Analyze lazily: mark dirty on change and compile once on the next request that needs the tree. A keystroke previously cost at least two compiles of the document and of every open file including it. - Track dependencies in JS instead of calling dependents() across N-API for every open document on every diagnostics pull. - Return semantic tokens as a flat Uint32Array sorted in C++, replacing six push calls per token. - Release a document's syntax tree on didClose. The workspace cache never evicted, so every file ever opened stayed resident. - Use the error_code filesystem overloads in recurse_collect, which walked the POL tree with throwing calls, and guard the remaining native call sites so a failure degrades one request. - Expose the compiler's existing Profile counters. --- native/cpp/napi/LSPDocument.cc | 90 +++++-- native/cpp/napi/LSPDocument.h | 1 + native/cpp/napi/LSPWorkspace.cc | 242 ++++++++++++++---- native/cpp/napi/LSPWorkspace.h | 15 ++ native/src/index.ts | 37 ++- native/test/native.spec.ts | 141 ++++++++++- server/src/server/connection.ts | 436 +++++++++++++++++++++++--------- 7 files changed, 781 insertions(+), 181 deletions(-) diff --git a/native/cpp/napi/LSPDocument.cc b/native/cpp/napi/LSPDocument.cc index 070c57c..ba28758 100644 --- a/native/cpp/napi/LSPDocument.cc +++ b/native/cpp/napi/LSPDocument.cc @@ -16,7 +16,9 @@ #include "bscript/compiler/model/CompilerWorkspace.h" #include "bscript/compilercfg.h" #include "clib/strutil.h" +#include #include +#include using namespace Pol::Bscript; @@ -131,10 +133,28 @@ Napi::Function LSPDocument::GetClass( Napi::Env env ) LSPDocument::InstanceMethod( "buildReferences", &LSPDocument::BuildReferences ), LSPDocument::InstanceMethod( "toFormattedString", &LSPDocument::ToFormattedString ), LSPDocument::InstanceMethod( "symbols", &LSPDocument::Symbols ), + LSPDocument::InstanceMethod( "release", &LSPDocument::Release ), LSPDocument::InstanceMethod( "dependents", &LSPDocument::Dependents ) } ); } +// Frees the syntax tree and diagnostics while keeping `referenced_by`, which +// the workspace-wide reference index still needs. +// +// LSPWorkspace's document cache holds a strong reference to every LSPDocument +// it ever creates and only clears on reopen, so without this the syntax tree of +// every file the user has opened stays resident for the life of the server. +// Reopening the document simply analyzes it again. +Napi::Value LSPDocument::Release( const Napi::CallbackInfo& info ) +{ + compiler_workspace.reset(); + if ( reporter ) + { + reporter->diagnostics.clear(); + } + return info.Env().Undefined(); +} + Napi::Value LSPDocument::Analyze( const Napi::CallbackInfo& info ) { auto env = info.Env(); @@ -148,6 +168,14 @@ Napi::Value LSPDocument::Analyze( const Napi::CallbackInfo& info ) compiler_workspace.reset(); auto* lsp_workspace = LSPWorkspace::Unwrap( workspace.Value() ); + + // Analyzing an .em/.inc means that file's own contents may have just + // changed, and get_contents() serves unsaved editor text. Its cached parse + // tree would otherwise be reused and the edit silently ignored. Analyzing a + // .src -- the common case -- leaves the cache intact, which is the point. + if ( type != LSPDocumentType::SRC ) + lsp_workspace->clear_parse_tree_caches(); + auto compiler = lsp_workspace->make_compiler(); if ( type == LSPDocumentType::INC || gExtensionConfiguration.referenceAllFunctions ) { @@ -171,6 +199,10 @@ Napi::Value LSPDocument::Analyze( const Napi::CallbackInfo& info ) build_references( *compiler_workspace ); } + // load() inserts into the parse-tree caches without bound; this trims them + // back to the size configured from ecompile.cfg. + lsp_workspace->prune_parse_tree_caches(); + return env.Undefined(); } catch ( const std::exception& ex ) @@ -235,32 +267,56 @@ Napi::Value LSPDocument::Diagnostics( const Napi::CallbackInfo& info ) return results; } +// Returns tokens as a flat Uint32Array of 5 values each (line, character, +// length, type, modifiers), already ordered by position. +// +// The previous shape was one JS array per token built with `push` calls, which +// on a few-thousand-token file meant tens of thousands of N-API calls plus a JS +// sort of that many arrays -- on every edit. Writing straight into the typed +// array's backing store avoids per-element crossings entirely. Napi::Value LSPDocument::Tokens( const Napi::CallbackInfo& info ) { auto env = info.Env(); - auto results = Napi::Array::New( env ); - auto push = results.Get( "push" ).As(); + if ( !compiler_workspace ) + { + return Napi::Uint32Array::New( env, 0 ); + } - if ( compiler_workspace ) + const auto& tokens = compiler_workspace->tokens; + + std::vector ordered; + ordered.reserve( tokens.size() ); + for ( const auto& token : tokens ) { - for ( const auto& token : compiler_workspace->tokens ) - { - auto semTok = Napi::Array::New( env ); - push.Call( semTok, { Napi::Number::New( env, token.line_number - 1 ) } ); - push.Call( semTok, { Napi::Number::New( env, token.character_column - 1 ) } ); - push.Call( semTok, { Napi::Number::New( env, token.length ) } ); - push.Call( semTok, { Napi::Number::New( env, static_cast( token.type ) ) } ); + ordered.push_back( &token ); + } - int modifiers = 0; - for ( auto const& modifier : token.modifiers ) - { - modifiers += ( 1 << static_cast( modifier ) ); - } - push.Call( semTok, { Napi::Number::New( env, modifiers ) } ); + std::sort( ordered.begin(), ordered.end(), + []( const Compiler::SemanticToken* a, const Compiler::SemanticToken* b ) + { + if ( a->line_number != b->line_number ) + return a->line_number < b->line_number; + return a->character_column < b->character_column; + } ); + + auto results = Napi::Uint32Array::New( env, ordered.size() * 5 ); + uint32_t* data = results.Data(); - push.Call( results, { semTok } ); + size_t index = 0; + for ( const auto* token : ordered ) + { + data[index++] = static_cast( token->line_number - 1 ); + data[index++] = static_cast( token->character_column - 1 ); + data[index++] = static_cast( token->length ); + data[index++] = static_cast( token->type ); + + uint32_t modifiers = 0; + for ( auto const& modifier : token->modifiers ) + { + modifiers |= ( 1u << static_cast( modifier ) ); } + data[index++] = modifiers; } return results; diff --git a/native/cpp/napi/LSPDocument.h b/native/cpp/napi/LSPDocument.h index 92c3e97..7cd2b34 100644 --- a/native/cpp/napi/LSPDocument.h +++ b/native/cpp/napi/LSPDocument.h @@ -44,6 +44,7 @@ class LSPDocument : public Napi::ObjectWrap Napi::Value BuildReferences( const Napi::CallbackInfo& ); Napi::Value ToFormattedString( const Napi::CallbackInfo& ); Napi::Value Symbols( const Napi::CallbackInfo& ); + Napi::Value Release( const Napi::CallbackInfo& ); std::unique_ptr reporter; diff --git a/native/cpp/napi/LSPWorkspace.cc b/native/cpp/napi/LSPWorkspace.cc index 6af971b..8681162 100644 --- a/native/cpp/napi/LSPWorkspace.cc +++ b/native/cpp/napi/LSPWorkspace.cc @@ -10,6 +10,7 @@ #include "plib/pkg.h" #include "plib/systemstate.h" +#include #include #include #include @@ -65,34 +66,85 @@ Napi::Function LSPWorkspace::GetClass( Napi::Env env ) LSPWorkspace::InstanceAccessor( "scripts", &LSPWorkspace::AutoCompiledScripts, nullptr ), LSPWorkspace::InstanceMethod( "cacheScripts", &LSPWorkspace::CacheCompiledScripts ), LSPWorkspace::InstanceMethod( "getDocument", &LSPWorkspace::GetDocument ), + LSPWorkspace::InstanceMethod( "clearParseTreeCache", &LSPWorkspace::ClearParseTreeCache ), + LSPWorkspace::InstanceAccessor( "profile", &LSPWorkspace::GetProfile, nullptr ), LSPWorkspace::InstanceAccessor( "autoCompiledScripts", &LSPWorkspace::AutoCompiledScripts, nullptr ) } ); } +// Collects script and include files beneath `basedir`. +// +// Every filesystem call below deliberately uses the non-throwing std::error_code +// overload. This walk runs during startup indexing across an entire POL tree, +// where a permission-denied directory, a directory junction, a dangling symlink, +// a file removed mid-walk, or a path exceeding MAX_PATH are all routine. The +// throwing overloads would raise std::filesystem_error out of an N-API method, +// which terminates the language server process. Unreadable entries are reported +// on stderr (piped to the "EScript Language Server" output channel) and skipped. void recurse_collect( const fs::path& basedir, std::set* files_src, std::set* files_inc ) { - if ( !fs::is_directory( basedir ) ) - return; std::error_code ec; - for ( auto dir_itr = fs::recursive_directory_iterator( basedir, ec ); - dir_itr != fs::recursive_directory_iterator(); ++dir_itr ) + + if ( !fs::is_directory( basedir, ec ) || ec ) + return; + + auto dir_itr = fs::recursive_directory_iterator( + basedir, fs::directory_options::skip_permission_denied, ec ); + if ( ec ) + { + fprintf( stderr, "[escript-lsp] Skipping unreadable directory '%s': %s\n", + basedir.string().c_str(), ec.message().c_str() ); + return; + } + + const auto end = fs::recursive_directory_iterator(); + while ( dir_itr != end ) { - if ( auto fn = dir_itr->path().filename().string(); !fn.empty() && *fn.begin() == '.' ) + const auto& path = dir_itr->path(); + + if ( auto fn = path.filename().string(); !fn.empty() && *fn.begin() == '.' ) { - if ( dir_itr->is_directory() ) + if ( dir_itr->is_directory( ec ) && !ec ) dir_itr.disable_recursion_pending(); - continue; + ec.clear(); + } + else if ( dir_itr->is_regular_file( ec ) && !ec ) + { + const auto ext = path.extension(); + const bool is_inc = !ext.compare( ".inc" ); + const bool is_src = !ext.compare( ".src" ) || !ext.compare( ".hsr" ) || + ( compilercfg.CompileAspPages && !ext.compare( ".asp" ) ); + + if ( is_inc || is_src ) + { + auto canonical = fs::canonical( path, ec ); + if ( ec ) + { + fprintf( stderr, "[escript-lsp] Skipping unresolvable file '%s': %s\n", + path.string().c_str(), ec.message().c_str() ); + ec.clear(); + } + else + { + ( is_inc ? files_inc : files_src )->insert( canonical.string() ); + } + } + } + else + { + ec.clear(); + } + + dir_itr.increment( ec ); + if ( ec ) + { + // Do not retry: a failing increment may not advance, which would spin. + fprintf( stderr, "[escript-lsp] Stopping directory walk under '%s': %s\n", + basedir.string().c_str(), ec.message().c_str() ); + break; } - else if ( !dir_itr->is_regular_file() ) - continue; - const auto ext = dir_itr->path().extension(); - if ( !ext.compare( ".inc" ) ) - files_inc->insert( fs::canonical( dir_itr->path() ).string() ); - else if ( !ext.compare( ".src" ) || !ext.compare( ".hsr" ) || - ( compilercfg.CompileAspPages && !ext.compare( ".asp" ) ) ) - files_src->insert( fs::canonical( dir_itr->path() ).string() ); } } @@ -149,27 +201,45 @@ Napi::Value LSPWorkspace::CacheCompiledScripts( const Napi::CallbackInfo& info ) return Napi::Value(); } - std::set files; + try + { + std::set files; - recurse_collect( fs::path( compilercfg.PolScriptRoot ), &files, &files ); - for ( const auto& pkg : Pol::Plib::systemstate.packages ) - recurse_collect( fs::path( pkg->dir() ), &files, &files ); + recurse_collect( fs::path( compilercfg.PolScriptRoot ), &files, &files ); + for ( const auto& pkg : Pol::Plib::systemstate.packages ) + recurse_collect( fs::path( pkg->dir() ), &files, &files ); - auto LSPWorkspace_ctor = env.GetInstanceData>() - ->Value() - .Get( "LSPDocument" ) - .As(); + auto LSPWorkspace_ctor = env.GetInstanceData>() + ->Value() + .Get( "LSPDocument" ) + .As(); - for ( const auto& path : files ) - { - if ( _cache.find( path ) == _cache.end() ) + for ( const auto& path : files ) { - auto document = LSPWorkspace_ctor.New( { Value(), Napi::String::New( env, path ) } ); - _cache[path] = Persistent( document ); - document.Get( "analyze" ).As().Call( document, {} ); + if ( _cache.find( path ) == _cache.end() ) + { + auto document = LSPWorkspace_ctor.New( { Value(), Napi::String::New( env, path ) } ); + _cache[path] = Persistent( document ); + document.Get( "analyze" ).As().Call( document, {} ); + } } - } + return env.Undefined(); + } + catch ( const Napi::Error& ) + { + throw; + } + catch ( const std::exception& ex ) + { + Napi::Error::New( env, std::string( "Error caching compiled scripts: " ) + ex.what() ) + .ThrowAsJavaScriptException(); + } + catch ( ... ) + { + Napi::Error::New( env, "Unknown error caching compiled scripts" ) + .ThrowAsJavaScriptException(); + } return env.Undefined(); } @@ -181,24 +251,45 @@ Napi::Value LSPWorkspace::AutoCompiledScripts( const Napi::CallbackInfo& info ) return CompiledScripts.Value(); } - std::set files; + auto env = info.Env(); - recurse_collect( fs::path( compilercfg.PolScriptRoot ), &files, &files ); - for ( const auto& pkg : Pol::Plib::systemstate.packages ) - recurse_collect( fs::path( pkg->dir() ), &files, &files ); + // This runs during startup indexing over the whole POL tree. An unhandled + // exception here previously terminated the language server with no output. + try + { + std::set files; - auto env = info.Env(); - auto results = Napi::Array::New( env ); - auto push = results.Get( "push" ).As(); + recurse_collect( fs::path( compilercfg.PolScriptRoot ), &files, &files ); + for ( const auto& pkg : Pol::Plib::systemstate.packages ) + recurse_collect( fs::path( pkg->dir() ), &files, &files ); - for ( const auto& path : files ) + auto results = Napi::Array::New( env, files.size() ); + + uint32_t index = 0; + for ( const auto& path : files ) + { + results.Set( index++, Napi::String::New( env, path ) ); + } + + results.Freeze(); + CompiledScripts.Reset( results ); + return results; + } + catch ( const Napi::Error& ) { - push.Call( results, { Napi::String::New( env, path ) } ); + throw; } - - results.Freeze(); - CompiledScripts.Reset( results ); - return results; + catch ( const std::exception& ex ) + { + Napi::Error::New( env, std::string( "Error collecting workspace scripts: " ) + ex.what() ) + .ThrowAsJavaScriptException(); + } + catch ( ... ) + { + Napi::Error::New( env, "Unknown error collecting workspace scripts" ) + .ThrowAsJavaScriptException(); + } + return Napi::Value(); } Napi::Value LSPWorkspace::Open( const Napi::CallbackInfo& info ) @@ -227,6 +318,9 @@ Napi::Value LSPWorkspace::Open( const Napi::CallbackInfo& info ) make_absolute( packageRoot ); } + configure_parse_tree_caches(); + clear_parse_tree_caches(); + CompiledScripts.Reset(); _cache.clear(); Pol::Plib::systemstate.packages.clear(); @@ -310,8 +404,11 @@ Napi::Value LSPWorkspace::Reopen( const Napi::CallbackInfo& info ) has_changes = true; } + configure_parse_tree_caches(); + if ( has_changes ) { + clear_parse_tree_caches(); CompiledScripts.Reset(); _cache.clear(); Pol::Plib::systemstate.packages.clear(); @@ -382,6 +479,65 @@ std::unique_ptr LSPWorkspace::make_compiler() profile ); } +void LSPWorkspace::configure_parse_tree_caches() +{ + // ecompile.cfg holds these as ints; a negative value would wrap to a huge + // unsigned limit and make the cache unbounded. + auto clamp = []( int size ) { return size > 0 ? static_cast( size ) : 0u; }; + + em_parse_tree_cache.configure( clamp( compilercfg.EmParseTreeCacheSize ) ); + inc_parse_tree_cache.configure( clamp( compilercfg.IncParseTreeCacheSize ) ); +} + +void LSPWorkspace::prune_parse_tree_caches() +{ + em_parse_tree_cache.keep_some(); + inc_parse_tree_cache.keep_some(); +} + +void LSPWorkspace::clear_parse_tree_caches() +{ + em_parse_tree_cache.clear(); + inc_parse_tree_cache.clear(); +} + +Napi::Value LSPWorkspace::ClearParseTreeCache( const Napi::CallbackInfo& info ) +{ + clear_parse_tree_caches(); + return info.Env().Undefined(); +} + +// Exposes the compiler's existing counters, which were previously collected but +// never readable from JS. Used to verify that include files are actually being +// cached rather than reparsed on every keystroke. +Napi::Value LSPWorkspace::GetProfile( const Napi::CallbackInfo& info ) +{ + auto env = info.Env(); + auto result = Napi::Object::New( env ); + + result["cacheHits"] = Napi::Number::New( env, static_cast( profile.cache_hits.load() ) ); + result["cacheMisses"] = + Napi::Number::New( env, static_cast( profile.cache_misses.load() ) ); + result["parseEmCount"] = + Napi::Number::New( env, static_cast( profile.parse_em_count.load() ) ); + result["parseIncCount"] = + Napi::Number::New( env, static_cast( profile.parse_inc_count.load() ) ); + result["parseSrcCount"] = + Napi::Number::New( env, static_cast( profile.parse_src_count.load() ) ); + result["buildWorkspaceMicros"] = + Napi::Number::New( env, static_cast( profile.build_workspace_micros.load() ) ); + result["analyzeMicros"] = + Napi::Number::New( env, static_cast( profile.analyze_micros.load() ) ); + result["parseEmMicros"] = + Napi::Number::New( env, static_cast( profile.parse_em_micros.load() ) ); + result["parseIncMicros"] = + Napi::Number::New( env, static_cast( profile.parse_inc_micros.load() ) ); + result["parseSrcMicros"] = + Napi::Number::New( env, static_cast( profile.parse_src_micros.load() ) ); + + return result; +} + Napi::Value LSPWorkspace::GetWorkspaceRoot( const Napi::CallbackInfo& info ) { return Napi::String::New( info.Env(), _workspaceRoot.generic_string() ); diff --git a/native/cpp/napi/LSPWorkspace.h b/native/cpp/napi/LSPWorkspace.h index 07e602a..a2a48fd 100644 --- a/native/cpp/napi/LSPWorkspace.h +++ b/native/cpp/napi/LSPWorkspace.h @@ -35,6 +35,8 @@ class LSPWorkspace : public Napi::ObjectWrap, Napi::Value AutoCompiledScripts( const Napi::CallbackInfo& ); Napi::Value CacheCompiledScripts( const Napi::CallbackInfo& ); Napi::Value GetDocument( const Napi::CallbackInfo& ); + Napi::Value ClearParseTreeCache( const Napi::CallbackInfo& ); + Napi::Value GetProfile( const Napi::CallbackInfo& ); std::string get_contents( const std::string& pathname ) const override; @@ -44,6 +46,19 @@ class LSPWorkspace : public Napi::ObjectWrap, LSPDocument* create_or_get_from_cache( const std::string& pathname ); + // Sizes the .em/.inc parse-tree caches from ecompile.cfg. Until this is + // called they keep zero entries, which makes SourceFileCache::load() bypass + // the cache entirely and reparse every included file on every analyze(). + void configure_parse_tree_caches(); + + // Trims both caches back to their configured size. load() inserts without + // bound, so this is what actually enforces the limit. + void prune_parse_tree_caches(); + + // Drops every cached parse tree. Required whenever an .inc/.em file changes, + // because contents may come from an unsaved editor buffer. + void clear_parse_tree_caches(); + private: void make_absolute( std::string& path ); diff --git a/native/src/index.ts b/native/src/index.ts index e945d4b..879248f 100644 --- a/native/src/index.ts +++ b/native/src/index.ts @@ -24,9 +24,34 @@ export type LSPWorkspaceConfig = { getXmlDocPath?: (moduleEmFile: string) => string | null; } +/** + * Counters collected by the compiler. `cacheHits`/`cacheMisses` and the + * `parse*Count` fields show whether include files are being reused across + * analyses or reparsed every time. + */ +export type CompilerProfile = { + cacheHits: number; + cacheMisses: number; + parseEmCount: number; + parseIncCount: number; + parseSrcCount: number; + buildWorkspaceMicros: number; + analyzeMicros: number; + parseEmMicros: number; + parseIncMicros: number; + parseSrcMicros: number; +} + export interface LSPWorkspace { new(config: LSPWorkspaceConfig): LSPWorkspace; workspaceRoot: string; + /** Snapshot of the compiler's counters. Read-only. */ + readonly profile: CompilerProfile; + /** + * Discards cached `.em`/`.inc` parse trees. Must be called whenever such a + * file changes, since contents may come from an unsaved editor buffer. + */ + clearParseTreeCache(): void; open(workspaceRoot: string): void; reopen(): boolean; // `true` if folder changes occurred in scripts/ecompile.cfg getConfigValue(key: 'PackageRoot'): Array; @@ -41,6 +66,12 @@ export interface LSPWorkspace { export interface LSPDocument { new(workspace: LSPWorkspace, pathname: string): LSPDocument; analyze(continueOnError?: boolean): void; + /** + * Frees this document's syntax tree and diagnostics, keeping the reference + * index. Call when the editor closes the document; a later `analyze()` + * restores it. + */ + release(): void; dependents(): string[]; diagnostics(): Diagnostic[]; hover(position: Position): string | undefined; @@ -49,7 +80,11 @@ export interface LSPDocument { references(position: Position): { range: Range, fsPath: string }[] | undefined; signatureHelp(position: Position): SignatureHelp | undefined; toFormattedString(options?: Partial>, formatRange?: Range): string; // throws - tokens(): [line: number, startChar: number, length: number, tokenType: number, tokenModifiers: number][]; + /** + * Semantic tokens as a flat array of 5 values each -- line, startChar, + * length, tokenType, tokenModifiers -- ordered by position. + */ + tokens(): Uint32Array; toStringTree(): string | undefined; buildReferences(): undefined; references(position: Position): Location[] | undefined; diff --git a/native/test/native.spec.ts b/native/test/native.spec.ts index 4163ce7..76cc7b7 100644 --- a/native/test/native.spec.ts +++ b/native/test/native.spec.ts @@ -3,7 +3,7 @@ import { readFileSync, readdirSync } from 'fs'; import { LSPDocument, LSPWorkspace, native } from '../src/index'; import { inspect } from 'util'; import { F_OK } from 'constants'; -import { writeFile, access, mkdir, readFile } from 'fs/promises'; +import { writeFile, access, mkdir, readFile, rm } from 'fs/promises'; import { dirname, join } from 'path'; import type { Range, Position } from 'vscode-languageclient/node'; @@ -1138,7 +1138,7 @@ describe('Tokens - SRC', () => { it('Can get regexp tokens', () => { const tokens = getTokens('/[a-z]+/i'); - expect(tokens).toEqual([[0, 0, 9, 20 /* regexp */, 0]]); + expect(Array.from(tokens)).toEqual([0, 0, 9, 20 /* regexp */, 0]); }); }); @@ -2150,3 +2150,140 @@ describe('Formatter', () => { }); } }); + +describe('XML doc robustness', () => { + // Regression: XmlDocParser tested `paramValue` twice and never `paramName`, + // so a element without a name attribute constructed a + // std::string from nullptr. That is undefined behaviour, and it was + // reachable from an ordinary hover over a module function. + const brokenDocDir = resolve(__dirname, 'xmldoc-malformed'); + const brokenDoc = join(brokenDocDir, 'basicioem.xml'); + + let document: LSPDocument; + let text: string; + + beforeAll(async () => { + await mkdir(brokenDocDir, { recursive: true }); + await writeFile(brokenDoc, ` + + + Print(anything, console_color:="") + + + Prints anything to the console. + + +`, 'utf-8'); + + const src = 'in-memory-file.src'; + const workspace = new LSPWorkspace({ + getContents: (pathname: string) => pathname === src ? text : readFileSync(pathname, 'utf-8'), + getXmlDocPath(moduleEmFile: string) { + return extname(moduleEmFile).toLowerCase() === '.em' ? brokenDoc : null; + } + }); + workspace.open(dir); + + document = new LSPDocument(workspace, src); + }); + + afterAll(async () => { + await rm(brokenDocDir, { recursive: true, force: true }); + }); + + it('Survives a parameter element with no name attribute', () => { + text = 'use basicio; Print( 1 );'; + document.analyze(); + + const hover = document.hover({ line: 1, character: 15 }); + + toBeDefined(hover); + expect(hover).toContain('Print'); + // Parsing continued past the malformed element rather than aborting. + expect(hover).toContain('Prints anything to the console.'); + }); +}); + +describe('Parse tree cache', () => { + // The .em/.inc caches were constructed but never configure()d, so + // SourceFileCache::load() bypassed them and every analyze() reparsed the + // whole include and module chain. + it('Reuses parsed includes across analyses of a .src', () => { + let text = 'use basicio; include "testutil"; Print( 1 );'; + + const src = resolve(dir, 'cache-probe.src'); + const workspace = new LSPWorkspace({ + getContents: (pathname: string) => pathname === src ? text : readFileSync(pathname, 'utf-8') + }); + workspace.open(dir); + + const document = workspace.getDocument(src); + + document.analyze(); + const before = { ...workspace.profile }; + + for (let i = 0; i < 5; i++) { + text = `use basicio; include "testutil"; Print( ${i} );`; + document.analyze(); + } + + const after = { ...workspace.profile }; + + expect(after.cacheHits - before.cacheHits).toBeGreaterThan(0); + expect(after.cacheMisses - before.cacheMisses).toEqual(0); + }); + + it('Re-reads an .em whose contents changed', () => { + // Analyzing an .em/.inc must drop the cached tree, since contents can + // come from an unsaved editor buffer. + let text: string; + let calls = 0; + + const workspace = new LSPWorkspace({ + getContents: () => { + calls++; + return text; + } + }); + workspace.open(dir); + + const document = workspace.getDocument('basicio.em'); + + text = 'Print(anything);'; + document.analyze(); + expect(document.diagnostics()).toHaveLength(0); + + text = 'if (1) endif'; + document.analyze(); + expect(document.diagnostics()).toHaveLength(1); + + expect(calls).toEqual(2); + }); +}); + +describe('Document release', () => { + // LSPWorkspace caches every document it creates and only clears on reopen, + // so a closed editor tab used to leave its syntax tree resident forever. + it('Drops the syntax tree and can rebuild it', () => { + let text = 'program foo() var x := 1; Print( x ); endprogram'; + + const src = resolve(dir, 'release-probe.src'); + const workspace = new LSPWorkspace({ + getContents: (pathname: string) => pathname === src ? text : readFileSync(pathname, 'utf-8') + }); + workspace.open(dir); + + const document = workspace.getDocument(src); + + document.analyze(); + expect(document.symbols()?.length).toBeGreaterThan(0); + + document.release(); + expect(document.symbols() ?? []).toHaveLength(0); + expect(document.diagnostics()).toHaveLength(0); + + // Reopening the document analyzes it again. + document.analyze(); + expect(document.symbols()?.length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/server/connection.ts b/server/src/server/connection.ts index e7becd4..87cd7f8 100644 --- a/server/src/server/connection.ts +++ b/server/src/server/connection.ts @@ -12,12 +12,25 @@ import DocsDownloader from '../workspace/DocsDownloader'; const { native } = require('../../../native/out/index') as typeof import('vscode-escript-native'); import type { ExtensionConfiguration } from 'vscode-escript-native'; import { deepEquals } from '../misc/Utils'; +import { beginOperation, endOperation, formatError, type BreadcrumbPosition } from '../misc/Diagnostics'; const { LSPWorkspace, LSPDocument, ExtensionConfiguration } = native; type LSPServerOptions = { storageFsPath: string; } +/** + * Files whose parse trees the compiler caches. Editing one of these invalidates + * the cache; editing a `.src` does not, which is the common case and where the + * caching actually pays off. + */ +const CACHED_PARSE_TREE_EXTENSIONS = ['.inc', '.em']; + +function isCachedParseTreeFile(fsPath: string): boolean { + const lowered = fsPath.toLowerCase(); + return CACHED_PARSE_TREE_EXTENSIONS.some(extension => lowered.endsWith(extension)); +} + export interface DidChangeConfigurationParams { configuration: ExtensionConfiguration } @@ -32,6 +45,23 @@ export class LSPServer { private workspace: typeof LSPWorkspace; public static options: Readonly; private sources: Map = new Map(); + + /** + * Documents whose analysis is out of date. Editing marks; the first request + * that actually needs the syntax tree pays for it. + * + * Analysis used to run eagerly on every change notification, and pull + * diagnostics then re-ran it, so a single keystroke cost at least two full + * compiles of the document and of every open file including it. + */ + private dirty: Set = new Set(); + + /** + * fsPath -> files that document includes, refreshed after each analysis. + * Cached so that marking dependents dirty stays in JS instead of calling + * across N-API for every open document on every keystroke. + */ + private dependsOn: Map = new Map(); private downloader: DocsDownloader; private configuration: ExtensionConfiguration | undefined; private updateCacheAbortController: AbortController | undefined; @@ -80,16 +110,69 @@ export class LSPServer { this.connection.listen(); } + /** + * Logging goes through the LSP connection so it is timestamped and + * level-tagged in the client's output channel. The fallback covers the + * window before the connection is listening. + */ + private log(message: string): void { + try { + this.connection.console.log(message); + } catch { + console.log(message); + } + } + + private logError(message: string, e?: unknown): void { + const text = e === undefined ? message : `${message}: ${formatError(e)}`; + try { + this.connection.console.error(text); + } catch { + console.error(text); + } + } + + private logWarn(message: string): void { + try { + this.connection.console.warn(message); + } catch { + console.warn(message); + } + } + + /** + * Runs a native call with a crash breadcrumb set, and degrades a failure to + * `fallback` rather than letting it escape the handler. + * + * Every method on `LSPDocument`/`LSPWorkspace` is a synchronous call into the + * compiler. Before this, most handlers invoked them bare: a native failure + * either rejected the request or, for anything the addon could not convert to + * a JS error, took the whole server process down with it. + */ + private guard(operation: string, fn: () => T, fallback: T, fsPath?: string, position?: BreadcrumbPosition): T { + beginOperation(operation, fsPath, position); + try { + return fn(); + } catch (ex) { + this.logError(`${operation} failed`, ex); + return fallback; + } finally { + endOperation(); + } + } + private onInitialize = async (params: InitializeParams): Promise => { - console.log('Got initialization params', params.capabilities); + // Indented: the client capabilities blob is several KB and unreadable + // as a single line in the output channel. + this.log(`Got initialization params ${JSON.stringify(params.capabilities, null, 2)}`); const workspaceFolders = params.workspaceFolders ?? []; const initializationOptions: InitializationOptions = params.initializationOptions; try { await mkdir(LSPServer.options.storageFsPath, { recursive: true }); } catch (ex) { - console.error(`Could not create storage directory '${LSPServer.options.storageFsPath}': ${ex} `); + this.logError(`Could not create storage directory '${LSPServer.options.storageFsPath}'`, ex); } let found = false; @@ -101,25 +184,30 @@ export class LSPServer { try { await access(polCfg, F_OK); await access(ecompileCfg, F_OK); - this.workspace.open(fsPath); - console.log(`Successfully read ${ecompileCfg}. Loading cache...`); + beginOperation('workspace/open', fsPath); + try { + this.workspace.open(fsPath); + } finally { + endOperation(); + } + this.log(`Successfully read ${ecompileCfg}. Loading cache...`); found = true; } catch (e) { - console.error(`Error reading ${ecompileCfg}`, e); + this.logError(`Error reading ${ecompileCfg}`, e); } } if (found) { this.onDidChangeConfiguration(initializationOptions); } else { - console.log(`Could not find pol.cfg;scripts/ecompile.cfg in [${workspaceFolders.map(x => x.uri).join(', ')}]`); + this.log(`Could not find pol.cfg;scripts/ecompile.cfg in [${workspaceFolders.map(x => x.uri).join(', ')}]`); } try { ExtensionConfiguration.setFromObject(initializationOptions?.configuration ?? {}); } catch (e) { - console.error('Error setting native configuration:', e); + this.logError('Error setting native configuration', e); } this.hasDiagnosticRelatedInformationCapability = Boolean(params.capabilities.textDocument?.publishDiagnostics?.relatedInformation); @@ -161,67 +249,140 @@ export class LSPServer { return result; }; + /** + * Marks a document, plus every open document that includes it, as needing + * re-analysis. + */ + private markDirty(fsPath: string): void { + this.dirty.add(fsPath); + + for (const dependeePathname of this.sources.keys()) { + if (dependeePathname === fsPath || this.dirty.has(dependeePathname)) { + continue; + } + if ((this.dependsOn.get(dependeePathname) ?? []).includes(fsPath)) { + this.dirty.add(dependeePathname); + } + } + } + + /** + * Brings a document's analysis up to date if it is stale. Safe to call + * before any request that reads the syntax tree; a no-op when clean. + */ + private ensureAnalyzed(fsPath: string, document: typeof LSPDocument): void { + if (!this.dirty.has(fsPath)) { + return; + } + this.dirty.delete(fsPath); + + if (isCachedParseTreeFile(fsPath)) { + this.workspace.clearParseTreeCache(); + } + + document.analyze(this.configuration?.continueAnalysisOnError); + this.dependsOn.set(fsPath, document.dependents()); + } + private onDidOpen = async (e: TextDocumentChangeEvent) => { const { fsPath } = URI.parse(e.document.uri); - this.sources.set(fsPath, this.workspace.getDocument(fsPath)); + const document = this.guard('textDocument/didOpen', () => this.workspace.getDocument(fsPath), undefined, fsPath); + if (document) { + this.sources.set(fsPath, document); + this.markDirty(fsPath); + } }; private onDidClose = async (e: TextDocumentChangeEvent) => { const { uri } = e.document; const { fsPath } = URI.parse(uri); + const document = this.sources.get(fsPath); this.sources.delete(fsPath); + this.dirty.delete(fsPath); + this.dependsOn.delete(fsPath); + + // The native workspace caches documents indefinitely, so the syntax + // tree has to be handed back explicitly or it outlives the editor tab. + if (document) { + this.guard('textDocument/didClose', () => document.release(), undefined, fsPath); + } }; private onDidChangeContent = async (e: TextDocumentChangeEvent) => { const { uri } = e.document; const { fsPath } = URI.parse(uri); - const document = this.sources.get(fsPath); - try { - if (!document) { - throw new Error('Document not opened'); - } - document.analyze(this.configuration?.continueAnalysisOnError); - } catch (ex) { - console.error(ex); + if (!this.sources.has(fsPath)) { + this.logError(`textDocument/didChange for a document that was never opened: ${fsPath}`); + return; } + + // Deliberately does no work here: the next request that needs the tree + // triggers exactly one analysis. + this.markDirty(fsPath); }; private onDocumentDiagnostics = async (e: DocumentDiagnosticParams): Promise => { const { uri } = e.textDocument; const { fsPath } = URI.parse(uri); - const document = this.sources.get(fsPath) ?? this.workspace.getDocument(fsPath); - this.sources.set(fsPath, document); - const diagnostics = document.diagnostics(); - - const relatedDocuments: {[uri: DocumentUri]: FullDocumentDiagnosticReport} = {}; - - for (const [dependeePathname, dependeeDoc] of this.sources.entries()) { - if (dependeePathname !== fsPath && dependeeDoc.dependents().includes(fsPath)) { - const uri = URI.file(dependeePathname).toString(); - dependeeDoc.analyze(); - const diagnostics = dependeeDoc.diagnostics(); - relatedDocuments[uri] = { - kind: DocumentDiagnosticReportKind.Full, - items: diagnostics - }; - } - } - return { + const empty: DocumentDiagnosticReport = { kind: DocumentDiagnosticReportKind.Full, - items: diagnostics, - relatedDocuments + items: [] }; + + return this.guard('textDocument/diagnostic', () => { + let document = this.sources.get(fsPath); + if (!document) { + document = this.workspace.getDocument(fsPath); + this.sources.set(fsPath, document); + this.markDirty(fsPath); + } + this.ensureAnalyzed(fsPath, document); + const diagnostics = document.diagnostics(); + + const relatedDocuments: {[uri: DocumentUri]: FullDocumentDiagnosticReport} = {}; + + for (const [dependeePathname, dependeeDoc] of this.sources.entries()) { + // Uses the cached dependency list rather than calling across + // N-API for every open document, and re-analyzes only those the + // change actually invalidated. + if (dependeePathname !== fsPath && (this.dependsOn.get(dependeePathname) ?? []).includes(fsPath)) { + const dependeeUri = URI.file(dependeePathname).toString(); + this.ensureAnalyzed(dependeePathname, dependeeDoc); + relatedDocuments[dependeeUri] = { + kind: DocumentDiagnosticReportKind.Full, + items: dependeeDoc.diagnostics() + }; + } + } + return { + kind: DocumentDiagnosticReportKind.Full, + items: diagnostics, + relatedDocuments + }; + }, empty, fsPath); }; private onDidChangeWatchedFiles = (e: DidChangeWatchedFilesParams) => { const ecompileCfg = join(this.workspace.workspaceRoot, 'scripts', 'ecompile.cfg'); const shouldReopen = e.changes.some(change => change.type === FileChangeType.Changed && URI.parse(change.uri).fsPath === ecompileCfg); + // An include or module file changed on disk (git checkout, external + // edit): anything cached from it is now stale. + const changedIncludes = e.changes + .map(change => URI.parse(change.uri).fsPath) + .filter(isCachedParseTreeFile); + + if (changedIncludes.length) { + this.guard('workspace/invalidateParseTreeCache', () => this.workspace.clearParseTreeCache(), undefined); + // Open documents including these files now hold stale analyses. + changedIncludes.forEach(changed => this.markDirty(changed)); + } + if (shouldReopen) { - const hasChanges = this.workspace.reopen(); + const hasChanges = this.guard('workspace/didChangeWatchedFiles', () => this.workspace.reopen(), false, ecompileCfg); if (hasChanges && this.configuration?.disableWorkspaceReferences === false) { this.updateCache(); } @@ -232,33 +393,34 @@ export class LSPServer { const { fsPath } = URI.parse(params.textDocument.uri); const document = this.sources.get(fsPath); - return document?.symbols() ?? null; + return this.guard('textDocument/documentSymbol', () => { + if (!document) { + return null; + } + this.ensureAnalyzed(fsPath, document); + return document.symbols() ?? null; + }, null, fsPath); }; private onSemanticTokens = async (params: SemanticTokensParams): Promise => { const builder = new SemanticTokensBuilder(); const { fsPath } = URI.parse(params.textDocument.uri); const document = this.sources.get(fsPath); - try { - if (!document) { - throw new Error('Document not opened'); - } - const tokens = document.tokens(); + if (!document) { + this.logError(`textDocument/semanticTokens for a document that was never opened: ${fsPath}`); + return builder.build(); + } - const sorted = tokens.sort((tokInfo1, tokInfo2) => { - const line = tokInfo1[0] - tokInfo2[0]; - if (line === 0) { - return tokInfo1[1] - tokInfo2[1]; - } - return line; - }); + this.guard('textDocument/semanticTokens', () => { + this.ensureAnalyzed(fsPath, document); + // Already sorted by position on the native side. + const tokens = document.tokens(); - for (const token of sorted) { - builder.push(...token); + for (let i = 0; i + 4 < tokens.length; i += 5) { + builder.push(tokens[i], tokens[i + 1], tokens[i + 2], tokens[i + 3], tokens[i + 4]); } - } catch (ex) { - console.error(ex); - } + }, undefined, fsPath); + return builder.build(); }; @@ -267,20 +429,23 @@ export class LSPServer { const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; const document = this.sources.get(fsPath); - if (document) { - const hover = document.hover(position); - if (hover) { - const contents: MarkupContent = { - value: hover, - kind: 'markdown' - }; - - return { - contents - }; + return this.guard('textDocument/hover', () => { + if (document) { + this.ensureAnalyzed(fsPath, document); + const hover = document.hover(position); + if (hover) { + const contents: MarkupContent = { + value: hover, + kind: 'markdown' + }; + + return { + contents + }; + } } - } - return null; + return null; + }, null, fsPath, position); }; private onDocumentRangeFormatting = async (params: DocumentRangeFormattingParams): Promise => { @@ -298,16 +463,19 @@ export class LSPServer { const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; const document = this.sources.get(fsPath); - if (document) { - const definition = document.definition(position); - if (definition) { - return { - range: definition.range, - uri: URI.file(definition.fsPath).toString() - }; + return this.guard('textDocument/definition', () => { + if (document) { + this.ensureAnalyzed(fsPath, document); + const definition = document.definition(position); + if (definition) { + return { + range: definition.range, + uri: URI.file(definition.fsPath).toString() + }; + } } - } - return null; + return null; + }, null, fsPath, position); }; private onCompletion = async (params: CompletionParams): Promise => { @@ -315,13 +483,16 @@ export class LSPServer { const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; const document = this.sources.get(fsPath); - if (document) { - const completion = document.completion(position); - if (completion) { - return completion; + return this.guard('textDocument/completion', () => { + if (document) { + this.ensureAnalyzed(fsPath, document); + const completion = document.completion(position); + if (completion) { + return completion; + } } - } - return null; + return null; + }, null, fsPath, position); }; private onSignatureHelp = async (params: SignatureHelpParams): Promise => { @@ -329,7 +500,13 @@ export class LSPServer { const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; const document = this.sources.get(fsPath); - return document?.signatureHelp(position) ?? null; + return this.guard('textDocument/signatureHelp', () => { + if (!document) { + return null; + } + this.ensureAnalyzed(fsPath, document); + return document.signatureHelp(position) ?? null; + }, null, fsPath, position); }; private onDidChangeConfiguration = (params: DidChangeConfigurationParams): void => { @@ -337,18 +514,18 @@ export class LSPServer { return; } - console.log('ExtensionConfiguration changed:', params); + this.log(`ExtensionConfiguration changed: ${JSON.stringify(params.configuration)}`); this.configuration = params.configuration; try { ExtensionConfiguration.setFromObject(params.configuration ?? {}); } catch (e) { - console.error('Error setting native configuration:', e); + this.logError('Error setting native configuration', e); } if (this.downloader.commitId === '' || params.configuration.polCommitId !== this.downloader.commitId) { this.downloader.start(this.workspace.workspaceRoot, this.workspace.getConfigValue('ModuleDirectory'), params.configuration.polCommitId).catch(e => { - console.warn(`Could not download polserver documentation: ${e?.message ?? e}`); + this.logWarn(`Could not download polserver documentation: ${formatError(e)}`); }); } @@ -366,39 +543,47 @@ export class LSPServer { const position: Position = { line: line + 1, character: character + 1 }; if (this.configuration?.disableWorkspaceReferences === false) { - const serverInitiatedReporter = await this.connection.window.createWorkDoneProgress(); + try { + const serverInitiatedReporter = await this.connection.window.createWorkDoneProgress(); - serverInitiatedReporter.begin('References', undefined, undefined, true); + serverInitiatedReporter.begin('References', undefined, undefined, true); - const controller = new AbortController(); - serverInitiatedReporter.token.onCancellationRequested(() => { - controller.abort(); - }); + const controller = new AbortController(); + serverInitiatedReporter.token.onCancellationRequested(() => { + controller.abort(); + }); - const loaded = await this.workspace.updateCache(({ count, total }) => { - serverInitiatedReporter.report(100 * count / total, `Waiting for workspace cache...`); - }, controller.signal); + const loaded = await this.workspace.updateCache(({ count, total }) => { + serverInitiatedReporter.report(100 * count / total, `Waiting for workspace cache...`); + }, controller.signal); - serverInitiatedReporter.done(); - if (!loaded) { + serverInitiatedReporter.done(); + if (!loaded) { + return undefined; + } + } catch (ex) { + this.logError('Building the workspace reference cache failed', ex); return undefined; } } const document = this.sources.get(fsPath); - if (document) { - const references = document.references(position); - if (references) { - if (params.context.includeDeclaration) { - const definition = document.definition(position, { nameOnly: true }); - if (definition) { - references.unshift(definition); + return this.guard('textDocument/references', () => { + if (document) { + this.ensureAnalyzed(fsPath, document); + const references = document.references(position); + if (references) { + if (params.context.includeDeclaration) { + const definition = document.definition(position, { nameOnly: true }); + if (definition) { + references.unshift(definition); + } } + return references.map(x => ({ ...x, uri: URI.file(x.fsPath).toString() })); } - return references.map(x => ({ ...x, uri: URI.file(x.fsPath).toString() })); } - } - return null; + return null; + }, null, fsPath, position); }; private updateCache() { @@ -411,14 +596,23 @@ export class LSPServer { // Must do next tick because `window/workDoneProgress/create` will not be registered yet. process.nextTick(async () => { - const serverInitiatedReporter = await this.connection.window.createWorkDoneProgress(); - serverInitiatedReporter.begin('Workspace Cache'); - this.workspace.updateCache(({ count, total }) => { - serverInitiatedReporter.report(100 * count / total, `Reading files ${count}/${total}`); - }, updateCacheAbortController.signal).then(() => { - serverInitiatedReporter.done(); - console.log(`Cache loaded.`); - }); + // Every await and every native call below is inside this try: an + // unhandled rejection here used to terminate the server outright, + // and it runs during startup indexing where it is least visible. + try { + const serverInitiatedReporter = await this.connection.window.createWorkDoneProgress(); + serverInitiatedReporter.begin('Workspace Cache'); + try { + await this.workspace.updateCache(({ count, total }) => { + serverInitiatedReporter.report(100 * count / total, `Reading files ${count}/${total}`); + }, updateCacheAbortController.signal); + this.log('Cache loaded.'); + } finally { + serverInitiatedReporter.done(); + } + } catch (ex) { + this.logError('Building the workspace cache failed', ex); + } }); } @@ -436,12 +630,18 @@ export class LSPServer { return null; } - const formatted = range ? + this.guard('textDocument/formatting/analyze', () => this.ensureAnalyzed(fsPath, document), undefined, fsPath); + + const formatted = this.guard('textDocument/formatting', () => range ? document.toFormattedString(options, { start: { line: range.start.line + 1, character: range.start.character }, end: { line: range.end.line + 1, character: range.end.character } }) : - document.toFormattedString(options); + document.toFormattedString(options), undefined, fsPath); + + if (typeof formatted === 'undefined') { + return null; + } const originalText = textDocument.getText(); From d604aa7d3f9bde2a56bed2786a39fd05036cda3c Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:19:31 +0200 Subject: [PATCH 04/15] Read unopened files in C++, expose every profile counter - get_contents() called into JS for every file the compiler loads, and the JS side answered with readFileSync for anything not open in an editor. That is an N-API round trip plus a sync read plus two string copies per file, on the path walked by every include of every analysis and by every file of the workspace index build -- almost none of which are open. JS now answers only for open documents, where the buffer may hold unsaved edits, and anything else falls through to SourceFileLoader::get_contents, which is what ecompile already uses. It is also the precondition for ever moving compilation off the main thread: while this override calls into JS, no compile can run on a worker. - Expose all of Profile, not the 10 fields of 23 that were reachable. The ones that were missing are the ones that became interesting once parsing was cached: optimize, disambiguate, tokenize, the per-stage ast* timers, and ambiguities. Measured with them, tokenize() alone is ~3.1ms of a ~12.5ms analyze() on a 1600-line file -- a quarter of every analysis, discarded unless semantic tokens are being served -- and astEm dominates the rest, confirming the caches save parsing but not AST construction. Two read badly without warning: astSrcMicros and astIncMicros are decremented by nested include time, so they are self-time and can go negative, and nothing resets any of them, so a caller measuring one operation has to diff two snapshots. - Drop CacheCompiledScripts. It re-walked the entire POL tree with recurse_collect rather than reusing the list AutoCompiledScripts already memoizes, and nothing in server/src ever called it. --- native/cpp/napi/LSPWorkspace.cc | 141 ++++++++++++++------------------ native/cpp/napi/LSPWorkspace.h | 1 - 2 files changed, 63 insertions(+), 79 deletions(-) diff --git a/native/cpp/napi/LSPWorkspace.cc b/native/cpp/napi/LSPWorkspace.cc index 8681162..822b949 100644 --- a/native/cpp/napi/LSPWorkspace.cc +++ b/native/cpp/napi/LSPWorkspace.cc @@ -64,7 +64,6 @@ Napi::Function LSPWorkspace::GetClass( Napi::Env env ) LSPWorkspace::InstanceMethod( "getConfigValue", &LSPWorkspace::GetConfigValue ), LSPWorkspace::InstanceAccessor( "workspaceRoot", &LSPWorkspace::GetWorkspaceRoot, nullptr ), LSPWorkspace::InstanceAccessor( "scripts", &LSPWorkspace::AutoCompiledScripts, nullptr ), - LSPWorkspace::InstanceMethod( "cacheScripts", &LSPWorkspace::CacheCompiledScripts ), LSPWorkspace::InstanceMethod( "getDocument", &LSPWorkspace::GetDocument ), LSPWorkspace::InstanceMethod( "clearParseTreeCache", &LSPWorkspace::ClearParseTreeCache ), LSPWorkspace::InstanceAccessor( "profile", &LSPWorkspace::GetProfile, nullptr ), @@ -191,58 +190,6 @@ LSPDocument* LSPWorkspace::create_or_get_from_cache( const std::string& path ) return LSPDocument::Unwrap( document ); } -Napi::Value LSPWorkspace::CacheCompiledScripts( const Napi::CallbackInfo& info ) -{ - auto env = info.Env(); - if ( info.Length() < 1 || !info[0].IsFunction() ) - { - Napi::TypeError::New( env, Napi::String::New( env, "Invalid arguments" ) ) - .ThrowAsJavaScriptException(); - return Napi::Value(); - } - - try - { - std::set files; - - recurse_collect( fs::path( compilercfg.PolScriptRoot ), &files, &files ); - for ( const auto& pkg : Pol::Plib::systemstate.packages ) - recurse_collect( fs::path( pkg->dir() ), &files, &files ); - - auto LSPWorkspace_ctor = env.GetInstanceData>() - ->Value() - .Get( "LSPDocument" ) - .As(); - - for ( const auto& path : files ) - { - if ( _cache.find( path ) == _cache.end() ) - { - auto document = LSPWorkspace_ctor.New( { Value(), Napi::String::New( env, path ) } ); - _cache[path] = Persistent( document ); - document.Get( "analyze" ).As().Call( document, {} ); - } - } - - return env.Undefined(); - } - catch ( const Napi::Error& ) - { - throw; - } - catch ( const std::exception& ex ) - { - Napi::Error::New( env, std::string( "Error caching compiled scripts: " ) + ex.what() ) - .ThrowAsJavaScriptException(); - } - catch ( ... ) - { - Napi::Error::New( env, "Unknown error caching compiled scripts" ) - .ThrowAsJavaScriptException(); - } - return env.Undefined(); -} - Napi::Value LSPWorkspace::AutoCompiledScripts( const Napi::CallbackInfo& info ) { @@ -450,11 +397,22 @@ void LSPWorkspace::make_absolute( std::string& path ) std::string LSPWorkspace::get_contents( const std::string& pathname ) const { auto value = GetContents.Call( Value(), { Napi::String::New( Env(), pathname ) } ); - if ( !value.IsString() ) + + // A string means the file is open in an editor and this is its buffer, unsaved + // edits included -- which is the whole reason contents are fetched through JS. + if ( value.IsString() ) { - throw std::runtime_error( "Could not get contents of file" ); + return value.As().Utf8Value(); } - return value.As().Utf8Value(); + + // Anything else means "not open in an editor". Reading it here instead of + // having JS answer with readFileSync drops an N-API round trip and two string + // copies per file -- on the path walked by every include of every analysis and + // by every file of the workspace index build, almost none of which are open. + // + // Delegating to the base implementation rather than opening the file here + // keeps the LSP reading files exactly the way ecompile does. + return SourceFileLoader::get_contents( pathname ); } std::optional LSPWorkspace::get_xml_doc_path( const std::string& moduleEmFile ) const @@ -508,32 +466,59 @@ Napi::Value LSPWorkspace::ClearParseTreeCache( const Napi::CallbackInfo& info ) } // Exposes the compiler's existing counters, which were previously collected but -// never readable from JS. Used to verify that include files are actually being -// cached rather than reparsed on every keystroke. +// never readable from JS. +// +// All of Profile's fields are surfaced, not just the parse/cache subset. Now that +// parsing is cached, the interesting costs are the phases that still re-run on +// every analyze -- `optimize`, `analyze`, `tokenize` -- and the AST rebuild that +// happens even on a parse-tree cache hit (`ast*Micros`). +// +// Nothing resets these: they are cumulative for the life of the workspace, so a +// caller has to diff two snapshots rather than read absolutes. +// +// Caveat inherited from the compiler: `astSrcMicros` and `astIncMicros` are +// decremented by nested include time, so they read as self-time and can be +// transiently negative. Napi::Value LSPWorkspace::GetProfile( const Napi::CallbackInfo& info ) { auto env = info.Env(); auto result = Napi::Object::New( env ); - result["cacheHits"] = Napi::Number::New( env, static_cast( profile.cache_hits.load() ) ); - result["cacheMisses"] = - Napi::Number::New( env, static_cast( profile.cache_misses.load() ) ); - result["parseEmCount"] = - Napi::Number::New( env, static_cast( profile.parse_em_count.load() ) ); - result["parseIncCount"] = - Napi::Number::New( env, static_cast( profile.parse_inc_count.load() ) ); - result["parseSrcCount"] = - Napi::Number::New( env, static_cast( profile.parse_src_count.load() ) ); - result["buildWorkspaceMicros"] = - Napi::Number::New( env, static_cast( profile.build_workspace_micros.load() ) ); - result["analyzeMicros"] = - Napi::Number::New( env, static_cast( profile.analyze_micros.load() ) ); - result["parseEmMicros"] = - Napi::Number::New( env, static_cast( profile.parse_em_micros.load() ) ); - result["parseIncMicros"] = - Napi::Number::New( env, static_cast( profile.parse_inc_micros.load() ) ); - result["parseSrcMicros"] = - Napi::Number::New( env, static_cast( profile.parse_src_micros.load() ) ); + auto set = [&]( const char* name, auto value ) + { result[name] = Napi::Number::New( env, static_cast( value ) ); }; + + set( "buildWorkspaceMicros", profile.build_workspace_micros.load() ); + set( "registerConstDeclarationsMicros", profile.register_const_declarations_micros.load() ); + set( "optimizeMicros", profile.optimize_micros.load() ); + set( "disambiguateMicros", profile.disambiguate_micros.load() ); + set( "analyzeMicros", profile.analyze_micros.load() ); + set( "tokenizeMicros", profile.tokenize_micros.load() ); + set( "codegenMicros", profile.codegen_micros.load() ); + set( "pruneCacheSelectMicros", profile.prune_cache_select_micros.load() ); + set( "pruneCacheDeleteMicros", profile.prune_cache_delete_micros.load() ); + + // An ANTLR ambiguity report means the SLL parse bailed and the file was + // re-parsed in full LL mode -- roughly twice the parse cost for that file. + set( "ambiguities", profile.ambiguities.load() ); + + set( "parseEmCount", profile.parse_em_count.load() ); + set( "parseIncCount", profile.parse_inc_count.load() ); + set( "parseSrcCount", profile.parse_src_count.load() ); + + set( "loadEmMicros", profile.load_em_micros.load() ); + set( "parseEmMicros", profile.parse_em_micros.load() ); + set( "astEmMicros", profile.ast_em_micros.load() ); + + set( "parseIncMicros", profile.parse_inc_micros.load() ); + set( "astIncMicros", profile.ast_inc_micros.load() ); + + set( "parseSrcMicros", profile.parse_src_micros.load() ); + set( "astSrcMicros", profile.ast_src_micros.load() ); + + set( "astResolveFunctionsMicros", profile.ast_resolve_functions_micros.load() ); + + set( "cacheHits", profile.cache_hits.load() ); + set( "cacheMisses", profile.cache_misses.load() ); return result; } @@ -577,4 +562,4 @@ Napi::Value LSPWorkspace::GetConfigValue( const Napi::CallbackInfo& info ) return Napi::Value(); } -} // namespace VSCodeEscript \ No newline at end of file +} // namespace VSCodeEscript diff --git a/native/cpp/napi/LSPWorkspace.h b/native/cpp/napi/LSPWorkspace.h index a2a48fd..76fed56 100644 --- a/native/cpp/napi/LSPWorkspace.h +++ b/native/cpp/napi/LSPWorkspace.h @@ -33,7 +33,6 @@ class LSPWorkspace : public Napi::ObjectWrap, Napi::Value GetConfigValue( const Napi::CallbackInfo& ); Napi::Value GetWorkspaceRoot( const Napi::CallbackInfo& ); Napi::Value AutoCompiledScripts( const Napi::CallbackInfo& ); - Napi::Value CacheCompiledScripts( const Napi::CallbackInfo& ); Napi::Value GetDocument( const Napi::CallbackInfo& ); Napi::Value ClearParseTreeCache( const Napi::CallbackInfo& ); Napi::Value GetProfile( const Napi::CallbackInfo& ); From fa53c6a6de0af176c2baa250fd33c0860dd05123 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:19:43 +0200 Subject: [PATCH 05/15] Rebuild the reference index after reopen, pace it by time - updateCache() memoizes its task and deliberately leaves the entry in place once it resolves, which is what makes every later textDocument/references free rather than an N-file recompile. But reopen() clears the native document cache when the ecompile.cfg folders change, and the memo outlived it: the caller asked for updateCache() again, the already-resolved promise answered immediately, and the index was silently never rebuilt for the new configuration. Workspace references simply stopped working until restart. Fixed with an explicit invalidateReferenceCache() rather than by wrapping reopen(): methods defined through N-API's DefineClass land on the prototype non-writable, so assigning over one throws. updateCache only gets away with it because it is a new property. - Yield on a time budget instead of once per file. A 1240-script distribution spent 1240 macrotask turns purely to let a pending request through, far more often than a request actually arrives. - Guard against a superseded build. Invalidation can replace a task while the old one is mid-loop; it now stops instead of writing into its replacement, and settling is scoped to the task that owns the progress reporters so a late finisher cannot clear its successor's. - Search more than one root for the compiled addon. __dirname moves when this file is bundled into the server, so the layouts are listed rather than assumed, and a change fails loudly in the existing error instead of silently resolving to nothing. - Widen getContents to return undefined for a document that is not open, matching the addon, and type the full set of profile counters. --- native/src/index.ts | 208 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 160 insertions(+), 48 deletions(-) diff --git a/native/src/index.ts b/native/src/index.ts index 879248f..9e2074d 100644 --- a/native/src/index.ts +++ b/native/src/index.ts @@ -20,26 +20,58 @@ export type SignatureHelp = { } export type LSPWorkspaceConfig = { - getContents: (pathname: string) => string; + /** + * Contents of an open document, or `undefined` when it is not open in an + * editor -- in which case the addon reads the file itself. Returning the + * file's contents from here anyway is still correct, just slower. + */ + getContents: (pathname: string) => string | undefined; getXmlDocPath?: (moduleEmFile: string) => string | null; } /** - * Counters collected by the compiler. `cacheHits`/`cacheMisses` and the - * `parse*Count` fields show whether include files are being reused across - * analyses or reparsed every time. + * Counters collected by the compiler, cumulative for the life of the workspace. + * Nothing resets them, so a caller measuring one operation must diff two + * snapshots rather than read absolutes. + * + * `cacheHits`/`cacheMisses` and the `parse*Count` fields show whether include + * files are being reused across analyses or reparsed every time. Now that + * parsing is cached, the phases that still re-run on every analyze -- + * `optimizeMicros`, `analyzeMicros`, `tokenizeMicros` -- and the AST rebuild + * that happens even on a cache hit (`ast*Micros`) are where the remaining time + * goes. + * + * Two readings need care: + * - `astSrcMicros` and `astIncMicros` are decremented by nested include time, + * so they are self-time and can be transiently negative. + * - `ambiguities` counts ANTLR ambiguity reports. Each one means the SLL parse + * bailed and the file was re-parsed in full LL mode, roughly doubling its + * parse cost. */ export type CompilerProfile = { cacheHits: number; cacheMisses: number; + ambiguities: number; parseEmCount: number; parseIncCount: number; parseSrcCount: number; buildWorkspaceMicros: number; + registerConstDeclarationsMicros: number; + optimizeMicros: number; + disambiguateMicros: number; analyzeMicros: number; + tokenizeMicros: number; + codegenMicros: number; + pruneCacheSelectMicros: number; + pruneCacheDeleteMicros: number; + loadEmMicros: number; parseEmMicros: number; + astEmMicros: number; parseIncMicros: number; + astIncMicros: number; parseSrcMicros: number; + astSrcMicros: number; + astResolveFunctionsMicros: number; } export interface LSPWorkspace { @@ -59,8 +91,13 @@ export interface LSPWorkspace { scripts: { inc: string[], src: string[] }; autoCompiledScripts: readonly string[]; getDocument(pathname: string): LSPDocument; - cacheScripts(...args: any[]): void; updateCache: typeof updateCache; + /** + * Drops the memoized workspace reference index so the next `updateCache()` + * rebuilds it instead of answering from the previous run. Call whenever + * `reopen()` reports configuration changes. + */ + invalidateReferenceCache(): void; } export interface LSPDocument { @@ -114,16 +151,31 @@ export interface EscriptVscodeNative { const baseFilename = `vscode-escript-native.${process.platform}-${process.arch}.node`; -const tries = [ - ...(process.platform === 'darwin' ? [ - [__dirname, '..', 'build', 'Debug', `vscode-escript-native.darwin-universal.node`], - [__dirname, '..', 'build', 'RelWithDebInfo', `vscode-escript-native.darwin-universal.node`], - [__dirname, '..', 'build', 'Release', `vscode-escript-native.darwin-universal.node`], - ] : []), - [__dirname, '..', 'build', 'Debug', baseFilename], - [__dirname, '..', 'build', 'RelWithDebInfo', baseFilename], - [__dirname, '..', 'build', 'Release', baseFilename], -].map(segment => resolve(...segment)); +/** + * Directories that may contain the `build/` tree holding the compiled addon. + * + * Unbundled -- tests, and the old extension layout -- this file runs from + * `native/out`, so `build/` is one level up. Bundled it runs from the bundle's + * own directory: `dist/` in the packaged extension, `server/out` if bundled in + * place. Each is listed rather than derived, so a layout change fails loudly in + * the error below instead of silently resolving to nothing. + */ +const roots = [ + resolve(__dirname, '..'), + resolve(__dirname, '..', 'native'), + resolve(__dirname, '..', '..', 'native') +]; + +const configurations = ['Debug', 'RelWithDebInfo', 'Release']; + +const filenames = [ + ...(process.platform === 'darwin' ? ['vscode-escript-native.darwin-universal.node'] : []), + baseFilename +]; + +const tries = roots.flatMap(root => + filenames.flatMap(name => + configurations.map(configuration => resolve(root, 'build', configuration, name)))); const filename = tries.find(filepath => existsSync(filepath)); @@ -135,37 +187,99 @@ if (!filename) { export type UpdateCacheProgressCallback = (progress: { count: number, total: number }) => void; export const native = require(filename) as EscriptVscodeNative; native.LSPWorkspace.prototype.updateCache = updateCache; +native.LSPWorkspace.prototype.invalidateReferenceCache = invalidateReferenceCache; -const updateCacheMap = new WeakMap, progresses: UpdateCacheProgressCallback[], signals: AbortSignal[] }>(); +type UpdateCacheTask = { + promise: Promise; + progresses: UpdateCacheProgressCallback[]; + signals: AbortSignal[]; +}; + +const updateCacheMap = new WeakMap(); + +/** + * How long the index build may hold the event loop before yielding, in + * milliseconds. + * + * It used to yield once per file, so a 1240-script distribution spent 1240 + * macrotask turns purely to let a pending request through -- far more often than + * a request actually arrives. A time budget keeps the server just as responsive + * while paying the scheduler once per budget instead of once per file. + */ +const UPDATE_CACHE_YIELD_MS = 5; + +/** + * Discards the memoized reference-index build. + * + * A completed build deliberately leaves its entry in `updateCacheMap`: that is + * what makes every later `textDocument/references` free rather than an N-file + * recompile. But `reopen()` clears the native document cache when the + * ecompile.cfg folders change, and the memo outlives it -- the caller then asks + * for `updateCache()`, the already-resolved promise answers immediately, and the + * index is silently never rebuilt for the new configuration. + * + * This cannot wrap `reopen()` itself: methods defined through N-API's + * DefineClass land on the prototype as non-writable, so assigning over one + * throws. `updateCache` gets away with it only because it is a new property. + */ +function invalidateReferenceCache(this: LSPWorkspace): void { + updateCacheMap.delete(this); +} function updateCache(this: LSPWorkspace, progress?: UpdateCacheProgressCallback, signal?: AbortSignal) { + // Progress reporters and abort signals belong to the caller that supplied + // them; once the build settles they are done with, and holding them would + // pin the reporter objects for the life of the workspace. + // + // Scoped to the task that owns them: a task superseded by invalidation + // settles *after* its replacement has already registered, and must not + // clear the replacement's reporters on its way out. + const settleFor = (owner: UpdateCacheTask) => (completed: boolean) => { + if (updateCacheMap.get(this) === owner) { + owner.progresses.length = 0; + owner.signals.length = 0; + } + return completed; + }; + const existing = updateCacheMap.get(this); if (existing) { - const { promise, progresses } = existing; if (progress) { - progresses.push(progress); + existing.progresses.push(progress); } - return promise.then((completed) => { - const existing = updateCacheMap.get(this); - if (existing) { - existing.progresses.length = 0; - existing.signals.length = 0; - } - return completed; - }); + if (signal) { + existing.signals.push(signal); + } + return existing.promise.then(settleFor(existing)); } - const update = async () => { + const update = async (): Promise => { const { autoCompiledScripts } = this; let count = 0; const total = autoCompiledScripts.length; + + // Hand control back before the first compile, so this call returns to + // its caller as promptly as it did when every file yielded. + await new Promise(resolve => setImmediate(resolve)); + let lastYield = Date.now(); + for (const p of autoCompiledScripts) { - await new Promise(resolve => setImmediate(resolve)); - const existing = updateCacheMap.get(this); - const canceled = Boolean(existing?.signals.some(signal => signal.aborted)); + if (Date.now() - lastYield >= UPDATE_CACHE_YIELD_MS) { + await new Promise(resolve => setImmediate(resolve)); + lastYield = Date.now(); + } - if (canceled) { - // Delete from the map, so a new call to updateCache() creates a new task. + const current = updateCacheMap.get(this); + + // A reopen() with folder changes replaced or dropped this task. A + // build started against the old configuration must not keep writing + // into the new one. + if (current !== task) { + return false; + } + + if (current.signals.some(aborter => aborter.aborted)) { + // Drop the entry so a later updateCache() starts a fresh task. updateCacheMap.delete(this); return false; } @@ -173,26 +287,24 @@ function updateCache(this: LSPWorkspace, progress?: UpdateCacheProgressCallback, try { this.getDocument(p).buildReferences(); } catch (e) { - // Should never happen - console.error(`Failed to process ${p}: ${e}`); + console.error(`Failed to process ${p}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`); } ++count; - if (existing) { - existing.progresses.forEach(progress => progress({ count, total })); - } + current.progresses.forEach(report => report({ count, total })); } return true; }; - const promise = update(); - updateCacheMap.set(this, { promise, progresses: progress ? [progress] : [], signals: signal ? [signal] : [] }); - - return promise.then((completed) => { - const existing = updateCacheMap.get(this); - if (existing) { - existing.progresses.length = 0; - existing.signals.length = 0; - } - return completed; - }); + + // Registered before the task starts: with a time budget the first files are + // compiled before any yield, and the loop checks its own registration. + const task: UpdateCacheTask = { + promise: undefined as unknown as Promise, + progresses: progress ? [progress] : [], + signals: signal ? [signal] : [] + }; + updateCacheMap.set(this, task); + task.promise = update(); + + return task.promise.then(settleFor(task)); } From ec32a47c8ed64c635fc58a30f0f8d87a01eb33fc Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:19:57 +0200 Subject: [PATCH 06/15] Honor cancellation, report the profile, drop async from the boot path - Answer cancelled requests instead of serving them. VSCode cancels superseded requests as the user keeps typing -- the hover from two keystrokes ago, a completion list the editor has already dismissed -- and every one of them still cost a full compile, because ensureAnalyzed() sits behind almost every handler. The reply is the protocol's RequestCancelled rather than an empty result on purpose: several of these drive persistent editor state (semantic tokens are the file's highlighting, diagnostics are the Problems panel) and an empty success would blank it, where an error leaves the client holding what it already had. No advertised capability changes; the token was always passed, just ignored. - Log the compiler's counters, which nothing outside the tests had ever read. Dumped after the workspace cache build, where the interesting split is, and on an escript/logProfile notification so a scripted LSP client can sample them mid-session. Zero-valued fields are dropped and micros are rendered as milliseconds so the line stays readable. - Stop answering getContents for files that are not open. The addon reads those itself now; returning them from here cost a round trip per file. - Invalidate the memoized reference index when reopen() reports folder changes, regardless of the disableWorkspaceReferences setting, so that re-enabling workspace references later cannot resurrect a stale index. - Replace async's eachLimit with a ten-line limiter. The package is ~225 KB of JavaScript parsed during server boot for a single call site that only runs when a POL workspace was found and the docs are not already on disk. The server/package-lock.json diff also corrects a staleness that predates this change: it recorded node-addon-api ^8.3.0 while native/package.json has said ^8.3.1 since before this branch. --- server/package-lock.json | 28 +----- server/package.json | 5 +- server/src/server/connection.ts | 133 +++++++++++++++++++++---- server/src/workspace/DocsDownloader.ts | 37 +++++-- 4 files changed, 145 insertions(+), 58 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 7b3ae07..4ee00e4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,15 +9,12 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "async": "^3.2.6", "vscode-escript-native": "file:../native", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, - "devDependencies": { - "@types/async": "^3.2.24" - }, + "devDependencies": {}, "engines": { "node": "*" } @@ -34,18 +31,6 @@ "node": "*" } }, - "node_modules/@types/async": { - "version": "3.2.24", - "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.24.tgz", - "integrity": "sha512-8iHVLHsCCOBKjCF2KwFe0p9Z3rfM9mL+sSP8btyR5vTjJRAqpBYD28/ZLgXPf0pjG1VxOvtCV/BgXkQbpSe8Hw==", - "dev": true - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, "node_modules/vscode-escript-native": { "resolved": "../native", "link": true @@ -101,17 +86,6 @@ } }, "dependencies": { - "@types/async": { - "version": "3.2.24", - "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.24.tgz", - "integrity": "sha512-8iHVLHsCCOBKjCF2KwFe0p9Z3rfM9mL+sSP8btyR5vTjJRAqpBYD28/ZLgXPf0pjG1VxOvtCV/BgXkQbpSe8Hw==", - "dev": true - }, - "async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" - }, "vscode-escript-native": { "version": "file:../native", "requires": { diff --git a/server/package.json b/server/package.json index 5e4bdce..b6a5d95 100644 --- a/server/package.json +++ b/server/package.json @@ -17,14 +17,11 @@ "url": "https://github.com/polserver/vscode-escript" }, "dependencies": { - "async": "^3.2.6", "vscode-escript-native": "file:../native", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, "scripts": {}, - "devDependencies": { - "@types/async": "^3.2.24" - } + "devDependencies": {} } diff --git a/server/src/server/connection.ts b/server/src/server/connection.ts index 87cd7f8..f50b0aa 100644 --- a/server/src/server/connection.ts +++ b/server/src/server/connection.ts @@ -1,7 +1,6 @@ -import { createConnection, TextDocuments, TextDocumentChangeEvent, ProposedFeatures, InitializeParams, DocumentSymbolParams, TextDocumentSyncKind, InitializeResult, SemanticTokensParams, SemanticTokensBuilder, SemanticTokens, Hover, HoverParams, MarkupContent, DefinitionParams, Location, CompletionParams, CompletionItem, SignatureHelpParams, SignatureHelp, ReferenceParams, DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportKind, DocumentUri, FullDocumentDiagnosticReport, DocumentFormattingParams, TextEdit, DocumentRangeFormattingParams, FormattingOptions, Range, DidChangeWatchedFilesParams, FileChangeType, DocumentSymbol } from 'vscode-languageserver/node'; +import { CancellationToken, ResponseError, LSPErrorCodes, createConnection, TextDocuments, TextDocumentChangeEvent, ProposedFeatures, InitializeParams, DocumentSymbolParams, TextDocumentSyncKind, InitializeResult, SemanticTokensParams, SemanticTokensBuilder, SemanticTokens, Hover, HoverParams, MarkupContent, DefinitionParams, Location, CompletionParams, CompletionItem, SignatureHelpParams, SignatureHelp, ReferenceParams, DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportKind, DocumentUri, FullDocumentDiagnosticReport, DocumentFormattingParams, TextEdit, DocumentRangeFormattingParams, FormattingOptions, Range, DidChangeWatchedFilesParams, FileChangeType, DocumentSymbol } from 'vscode-languageserver/node'; import { Position, TextDocument } from 'vscode-languageserver-textdocument'; import { URI } from 'vscode-uri'; -import { readFileSync } from 'fs'; import { access, mkdir } from 'fs/promises'; import { join } from 'path'; import { F_OK } from 'constants'; @@ -10,7 +9,7 @@ import DocsDownloader from '../workspace/DocsDownloader'; // vsce does not support symlinks // import { escript } from 'vscode-escript-native'; const { native } = require('../../../native/out/index') as typeof import('vscode-escript-native'); -import type { ExtensionConfiguration } from 'vscode-escript-native'; +import type { CompilerProfile, ExtensionConfiguration } from 'vscode-escript-native'; import { deepEquals } from '../misc/Utils'; import { beginOperation, endOperation, formatError, type BreadcrumbPosition } from '../misc/Diagnostics'; const { LSPWorkspace, LSPDocument, ExtensionConfiguration } = native; @@ -86,6 +85,10 @@ export class LSPServer { this.connection.onCompletion(this.onCompletion); this.connection.onSignatureHelp(this.onSignatureHelp); this.connection.onNotification('didChangeConfiguration', this.onDidChangeConfiguration); + // No client UI drives this: it exists so a scripted LSP client -- which + // is how this server's performance work gets measured -- can read the + // counters at an arbitrary point in a session. + this.connection.onNotification('escript/logProfile', () => this.logProfile('on demand')); this.connection.onReferences(this.onReferences); this.connection.languages.diagnostics.on(this.onDocumentDiagnostics); this.connection.onDidChangeWatchedFiles(this.onDidChangeWatchedFiles); @@ -94,14 +97,12 @@ export class LSPServer { this.documents.listen(this.connection); this.downloader = new DocsDownloader(LSPServer.options.storageFsPath); this.workspace = new LSPWorkspace({ - getContents: (pathname) => { - const uri = URI.file(pathname).toString(); - const text = this.documents.get(uri)?.getText(); - if (typeof text !== 'undefined') { - return text; - } - return readFileSync(pathname, 'utf-8'); - }, + // Answers only for documents open in an editor, where the buffer + // may hold unsaved edits the file does not. Everything else -- + // every include pulled in by an analysis, every file of the + // workspace index build -- is read by the addon directly, which + // saves an N-API round trip and two string copies per file. + getContents: (pathname) => this.documents.get(URI.file(pathname).toString())?.getText(), getXmlDocPath: this.downloader.getXmlDocPath.bind(this.downloader) }); } @@ -140,6 +141,48 @@ export class LSPServer { } } + /** + * Renders the compiler's counters for the output channel. + * + * `*Micros` fields are reported as milliseconds, and zero-valued entries are + * dropped so the line stays readable. The counters are cumulative and are + * never reset, so anything measuring a single operation has to pass the + * snapshot it took beforehand as `since`. + * + * Two of them lie if read naively: `astSrcMicros` and `astIncMicros` are + * decremented by nested include time, so they are self-time and can come out + * negative. `ambiguities` counts SLL parse failures, each of which cost a + * full re-parse in LL mode. + */ + private formatProfile(profile: CompilerProfile, since?: CompilerProfile): string { + const parts = Object.entries(profile) + .map(([key, value]) => { + const delta = since ? value - (since[key as keyof CompilerProfile] ?? 0) : value; + return [key, delta] as const; + }) + .filter(([, delta]) => delta !== 0) + .map(([key, delta]) => key.endsWith('Micros') + ? `${key.slice(0, -'Micros'.length)}=${(delta / 1000).toFixed(1)}ms` + : `${key}=${delta}`); + + return parts.length ? parts.join(' ') : '(no change)'; + } + + /** + * Snapshot of the compiler's counters, or `undefined` if the workspace was + * never opened. + */ + private profileSnapshot(): CompilerProfile | undefined { + return this.guard('workspace/profile', () => this.workspace.profile, undefined); + } + + private logProfile(label: string, since?: CompilerProfile): void { + const profile = this.profileSnapshot(); + if (profile) { + this.log(`Compiler profile [${label}]: ${this.formatProfile(profile, since)}`); + } + } + /** * Runs a native call with a crash breadcrumb set, and degrades a failure to * `fallback` rather than letting it escape the handler. @@ -161,6 +204,26 @@ export class LSPServer { } } + /** + * Aborts a request the client has already given up on. + * + * VSCode cancels superseded requests as the user keeps typing: the hover + * from two keystrokes ago, a completion list the editor has since + * dismissed. Every one of them still cost a full compile, because + * `ensureAnalyzed()` sits behind almost every handler below. + * + * This answers with the protocol's `RequestCancelled` rather than an empty + * result on purpose. Several of these requests drive persistent editor + * state -- semantic tokens are the file's highlighting, diagnostics are the + * Problems panel -- and an empty success would blank it. An error response + * leaves the client holding what it already had. + */ + private throwIfCancelled(token?: CancellationToken): void { + if (token?.isCancellationRequested) { + throw new ResponseError(LSPErrorCodes.RequestCancelled, 'Request cancelled'); + } + } + private onInitialize = async (params: InitializeParams): Promise => { // Indented: the client capabilities blob is several KB and unreadable @@ -323,7 +386,9 @@ export class LSPServer { this.markDirty(fsPath); }; - private onDocumentDiagnostics = async (e: DocumentDiagnosticParams): Promise => { + private onDocumentDiagnostics = async (e: DocumentDiagnosticParams, token?: CancellationToken): Promise => { + this.throwIfCancelled(token); + const { uri } = e.textDocument; const { fsPath } = URI.parse(uri); @@ -383,13 +448,26 @@ export class LSPServer { if (shouldReopen) { const hasChanges = this.guard('workspace/didChangeWatchedFiles', () => this.workspace.reopen(), false, ecompileCfg); - if (hasChanges && this.configuration?.disableWorkspaceReferences === false) { - this.updateCache(); + if (hasChanges) { + // reopen() drops the native document cache, but the reference + // index build is memoized in JS and a finished one answers + // instantly -- which is exactly what keeps repeat + // textDocument/references cheap. Unless it is dropped here the + // index is never rebuilt for the new configuration. Done + // regardless of the setting, so that re-enabling workspace + // references later does not resurrect a stale index. + this.guard('workspace/invalidateReferenceCache', () => this.workspace.invalidateReferenceCache(), undefined); + + if (this.configuration?.disableWorkspaceReferences === false) { + this.updateCache(); + } } } }; - private onDocumentSymbol = (params: DocumentSymbolParams): DocumentSymbol[] | null => { + private onDocumentSymbol = (params: DocumentSymbolParams, token?: CancellationToken): DocumentSymbol[] | null => { + this.throwIfCancelled(token); + const { fsPath } = URI.parse(params.textDocument.uri); const document = this.sources.get(fsPath); @@ -402,7 +480,9 @@ export class LSPServer { }, null, fsPath); }; - private onSemanticTokens = async (params: SemanticTokensParams): Promise => { + private onSemanticTokens = async (params: SemanticTokensParams, token?: CancellationToken): Promise => { + this.throwIfCancelled(token); + const builder = new SemanticTokensBuilder(); const { fsPath } = URI.parse(params.textDocument.uri); const document = this.sources.get(fsPath); @@ -424,7 +504,9 @@ export class LSPServer { return builder.build(); }; - private onHover = (params: HoverParams): Hover | null => { + private onHover = (params: HoverParams, token?: CancellationToken): Hover | null => { + this.throwIfCancelled(token); + const { fsPath } = URI.parse(params.textDocument.uri); const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; @@ -458,7 +540,9 @@ export class LSPServer { return this.getFormattedTextEdit(uri, options); }; - private onDefinition = async (params: DefinitionParams): Promise => { + private onDefinition = async (params: DefinitionParams, token?: CancellationToken): Promise => { + this.throwIfCancelled(token); + const { fsPath } = URI.parse(params.textDocument.uri); const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; @@ -478,7 +562,9 @@ export class LSPServer { }, null, fsPath, position); }; - private onCompletion = async (params: CompletionParams): Promise => { + private onCompletion = async (params: CompletionParams, token?: CancellationToken): Promise => { + this.throwIfCancelled(token); + const { fsPath } = URI.parse(params.textDocument.uri); const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; @@ -495,7 +581,9 @@ export class LSPServer { }, null, fsPath, position); }; - private onSignatureHelp = async (params: SignatureHelpParams): Promise => { + private onSignatureHelp = async (params: SignatureHelpParams, token?: CancellationToken): Promise => { + this.throwIfCancelled(token); + const { fsPath } = URI.parse(params.textDocument.uri); const { position: { line, character } } = params; const position: Position = { line: line + 1, character: character + 1 }; @@ -602,11 +690,14 @@ export class LSPServer { try { const serverInitiatedReporter = await this.connection.window.createWorkDoneProgress(); serverInitiatedReporter.begin('Workspace Cache'); + const before = this.profileSnapshot(); + const startedAt = Date.now(); try { await this.workspace.updateCache(({ count, total }) => { serverInitiatedReporter.report(100 * count / total, `Reading files ${count}/${total}`); }, updateCacheAbortController.signal); - this.log('Cache loaded.'); + this.log(`Cache loaded in ${Date.now() - startedAt}ms.`); + this.logProfile('workspace cache build', before); } finally { serverInitiatedReporter.done(); } diff --git a/server/src/workspace/DocsDownloader.ts b/server/src/workspace/DocsDownloader.ts index 94204f8..180dad0 100644 --- a/server/src/workspace/DocsDownloader.ts +++ b/server/src/workspace/DocsDownloader.ts @@ -6,9 +6,38 @@ import { readdir, mkdir, access } from 'fs/promises'; import { createWriteStream, unlink } from 'fs'; import * as http from 'http'; import * as https from 'https'; -import { eachLimit } from 'async'; import { F_OK } from 'constants'; +/** + * Runs `worker` over `items`, at most `limit` of them in flight, and rejects + * with the first failure without starting anything further. + * + * Replaces `async`'s `eachLimit`, whose package is ~225 KB of JavaScript parsed + * during server boot for this single call site -- one that only runs when a POL + * workspace was found and the documentation for that revision is not already on + * disk. + */ +async function eachLimit(items: readonly T[], limit: number, worker: (item: T) => Promise): Promise { + let next = 0; + let failure: unknown; + + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length && failure === undefined) { + try { + await worker(items[next++]); + } catch (e) { + failure ??= e; + } + } + }); + + await Promise.all(runners); + + if (failure !== undefined) { + throw failure; + } +} + export default class DocsDownloader { private static readonly POL_REV_REGEX = /\(Rev. ([0-9a-fA-F]{9})\)/; public commitId: string | null = null; @@ -39,11 +68,7 @@ export default class DocsDownloader { if (commitId) { const _commitId = commitId; const modules = await this.availableModules(moduleDirectory); - await eachLimit(modules, 5, (moduleName, cb) => { - this.downloadDoc(_commitId, moduleName) - .then(_ => cb()) - .catch(e => cb(e)); - }); + await eachLimit(modules, 5, moduleName => this.downloadDoc(_commitId, moduleName)); } } From 5f68f6fb0452d92c7f3b7906eb0b9211a48d33cf Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:20:07 +0200 Subject: [PATCH 07/15] Stop the index build leaking, drop the per-item N-API calls - BuildReferences() reported into the member reporter rather than the local one it had just constructed and then ignored, and unlike Analyze() it never cleared it first. So the workspace cache build appended every diagnostic of every file in the distribution into that document's persistent reporter, which nothing ever clears: memory never reclaimed, and diagnostics() answering with stale results for a document the user had never opened. - BuildReferences() also never pruned the parse-tree caches. load() inserts on a miss without bound and keep_some() is the only thing that enforces the size configured from ecompile.cfg, so the one path that touches every file in the workspace grew both caches to the workspace's entire include set and never trimmed. This is the hole the previous round closed for Analyze() and left open here; ecompile calls keep_some() after every file for the same reason. - Build the result arrays by index instead of calling push() per element. Each of these looked up "push" on the array and then made a JS call per item: Diagnostics for the document and every open dependent on every diagnostics pull, Dependents after every analyze, Completion per keystroke, References per request. Same treatment tokens() already got. --- native/cpp/napi/LSPDocument.cc | 99 ++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/native/cpp/napi/LSPDocument.cc b/native/cpp/napi/LSPDocument.cc index ba28758..b993940 100644 --- a/native/cpp/napi/LSPDocument.cc +++ b/native/cpp/napi/LSPDocument.cc @@ -220,8 +220,11 @@ Napi::Value LSPDocument::Diagnostics( const Napi::CallbackInfo& info ) { auto env = info.Env(); + // Indexed Set() rather than push(): this runs for the document *and* every + // open document that includes it, on every diagnostics pull, and a push() per + // item costs a property lookup plus a JS call each. auto results = Napi::Array::New( env ); - auto push = results.Get( "push" ).As(); + uint32_t index = 0; for ( const auto& diagnostic : reporter->diagnostics ) { @@ -261,7 +264,7 @@ Napi::Value LSPDocument::Diagnostics( const Napi::CallbackInfo& info ) env, diagnostic.severity == Compiler::Diagnostic::Severity::Error ? 1 : 2 ); diag["message"] = Napi::String::New( env, diagnostic.message ); - push.Call( results, { diag } ); + results.Set( index++, diag ); } return results; @@ -326,15 +329,18 @@ Napi::Value LSPDocument::Dependents( const Napi::CallbackInfo& info ) { auto env = info.Env(); - auto results = Napi::Array::New( env ); - auto push = results.Get( "push" ).As(); + if ( !compiler_workspace ) + { + return Napi::Array::New( env, 0 ); + } - if ( compiler_workspace ) + const auto& identifiers = compiler_workspace->referenced_source_file_identifiers; + auto results = Napi::Array::New( env, identifiers.size() ); + + uint32_t index = 0; + for ( const auto& sourceId : identifiers ) { - for ( const auto& sourceId : compiler_workspace->referenced_source_file_identifiers ) - { - push.Call( results, { Napi::String::New( env, sourceId->pathname ) } ); - } + results.Set( index++, Napi::String::New( env, sourceId->pathname ) ); } return results; @@ -494,10 +500,11 @@ Napi::Value LSPDocument::References( const Napi::CallbackInfo& info ) auto references = finder.context(); if ( references.has_value() ) { - auto results = Napi::Array::New( env ); - auto push = results.Get( "push" ).As(); + const auto& locations = references.value(); + auto results = Napi::Array::New( env, locations.size() ); - for ( const auto& location : references.value() ) + uint32_t index = 0; + for ( const auto& location : locations ) { const auto& locationRange = location.range; auto result = Napi::Object::New( env ); @@ -514,7 +521,7 @@ Napi::Value LSPDocument::References( const Napi::CallbackInfo& info ) result["range"] = range; result["fsPath"] = location.pathname; - push.Call( results, { result } ); + results.Set( index++, result ); } return results; @@ -533,36 +540,37 @@ Napi::Value LSPDocument::Completion( const Napi::CallbackInfo& info ) .ThrowAsJavaScriptException(); } - auto results = Napi::Array::New( env ); - auto push = results.Get( "push" ).As(); + if ( !compiler_workspace ) + { + return Napi::Array::New( env, 0 ); + } - if ( compiler_workspace ) + auto position = info[0].As(); + auto line = position.Get( "line" ); + auto character = position.Get( "character" ); + if ( !line.IsNumber() || !character.IsNumber() ) { - auto position = info[0].As(); - auto line = position.Get( "line" ); - auto character = position.Get( "character" ); - if ( !line.IsNumber() || !character.IsNumber() ) - { - Napi::TypeError::New( env, Napi::String::New( env, "Invalid arguments" ) ) - .ThrowAsJavaScriptException(); - } - Compiler::Position pos{ - static_cast( line.As().Int32Value() ), - static_cast( character.As().Int32Value() ) }; + Napi::TypeError::New( env, Napi::String::New( env, "Invalid arguments" ) ) + .ThrowAsJavaScriptException(); + } + Compiler::Position pos{ static_cast( line.As().Int32Value() ), + static_cast( character.As().Int32Value() ) }; - CompilerExt::CompletionBuilder finder( *compiler_workspace, pos ); - auto definition = finder.context(); - for ( const auto& completionItem : definition ) + CompilerExt::CompletionBuilder finder( *compiler_workspace, pos ); + auto completionItems = finder.context(); + + auto results = Napi::Array::New( env, completionItems.size() ); + uint32_t index = 0; + for ( const auto& completionItem : completionItems ) + { + auto result = Napi::Object::New( env ); + result["label"] = completionItem.label; + if ( completionItem.kind.has_value() ) { - auto result = Napi::Object::New( env ); - result["label"] = completionItem.label; - if ( completionItem.kind.has_value() ) - { - result["kind"] = - Napi::Number::New( env, static_cast( completionItem.kind.value() ) ); - } - push.Call( results, { result } ); + result["kind"] = + Napi::Number::New( env, static_cast( completionItem.kind.value() ) ); } + results.Set( index++, result ); } return results; } @@ -658,8 +666,14 @@ Napi::Value LSPDocument::BuildReferences( const Napi::CallbackInfo& info ) } else { + // Report into the local reporter, not the member. `local_reporter` was + // created and then ignored, so the workspace cache build -- which runs this + // for every script in the distribution -- appended every diagnostic of every + // file into that document's persistent reporter, which nothing ever clears. + // Unbounded memory, and diagnostics() would answer with stale results for a + // document the user had never opened. auto local_reporter = std::make_unique(); - auto local_report = std::make_unique( *reporter ); + auto local_report = std::make_unique( *local_reporter ); auto* lsp_workspace = LSPWorkspace::Unwrap( workspace.Value() ); auto compiler = lsp_workspace->make_compiler(); @@ -682,6 +696,13 @@ Napi::Value LSPDocument::BuildReferences( const Napi::CallbackInfo& info ) { build_references( *local_compiler_workspace ); } + + // Analyze() prunes; this path did not. load() inserts into the em/inc caches + // without bound and keep_some() is the only thing that enforces the size + // configured from ecompile.cfg, so the one path that touches every file in + // the workspace grew both caches to the whole include set and never trimmed. + // ecompile calls keep_some() after every file for the same reason. + lsp_workspace->prune_parse_tree_caches(); } return env.Undefined(); From fd80f0d527d7394e68941ca7300c3d143f3a8da7 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:20:21 +0200 Subject: [PATCH 08/15] Prune the per-request parse-tree walk by position SemanticContextBuilder::visitChildren recursed into every child unconditionally, with a dynamic_cast per node, to collect the contexts containing the caret. A rule context spans all of its children, so the contexts that can contain a position are only ever a single root-to-leaf chain -- the rest of the file was walked for nothing, on every hover, definition, completion, signature help and references request. Measured on a 1610-line fixture, probing near the end of the file: hover 1.544 ms -> 0.099 ms (15.6x) definition 1.376 ms -> 0.046 ms (30x) The gain scales with file size; on a 17-line fixture the same change is worth about 35%. The guard is on the stop token rather than on contains() alone. An incomplete rule has no stop token, calculate_end_position() gives it end {0,0}, and contains() is then false for every position -- routine while the user is mid-keystroke, so such a subtree still has to be descended into or completion would break on exactly the half-typed code it serves. Also: - Add token_length(), for the position tests that only ever wanted a length and paid for a std::string to get it. Kept to two virtual calls: an earlier version also fetched the input stream and its size and benchmarked no faster than getText(), because a token short enough for the string's small-buffer optimisation never reaches the heap, so the allocation this was meant to avoid mostly was not happening while the extra dispatch was. - Stop the completion scan running to end of file. It cannot become a binary search -- it accumulates the enclosing class/function/enum state on the way down -- but it can stop once past the caret's line. - Delete a full copy of the file's token vector in HoverBuilder that was assigned and never read. get_all_tokens() returns by value, so this allocated and filled a vector of every token pointer on every hover. --- native/cpp/compiler/CompletionBuilder.cc | 13 +++++- native/cpp/compiler/HoverBuilder.cc | 1 - native/cpp/compiler/SemanticContextBuilder.h | 22 ++++++++-- native/cpp/compiler/TokenLength.h | 45 ++++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 native/cpp/compiler/TokenLength.h diff --git a/native/cpp/compiler/CompletionBuilder.cc b/native/cpp/compiler/CompletionBuilder.cc index ab6a279..c3e4102 100644 --- a/native/cpp/compiler/CompletionBuilder.cc +++ b/native/cpp/compiler/CompletionBuilder.cc @@ -1,5 +1,7 @@ #include "CompletionBuilder.h" +#include "TokenLength.h" + #include #include "bscript/compiler/ast/ClassDeclaration.h" @@ -94,9 +96,18 @@ std::vector CompletionBuilder::context() } } + // Past the line we want: tokens are in source order, so nothing further can + // match. The scan has to start from the top -- it accumulates the enclosing + // class/function/enum state above -- but it never needed to run to the end + // of the file. + if ( token->getLine() > position.line_number ) + { + break; + } + if ( token->getLine() == position.line_number && token->getCharPositionInLine() + 1 <= position.character_column && - token->getCharPositionInLine() + 1 + token->getText().length() >= + token->getCharPositionInLine() + 1 + token_length( token ) >= position.character_column ) { result = token; diff --git a/native/cpp/compiler/HoverBuilder.cc b/native/cpp/compiler/HoverBuilder.cc index 79e0d5a..cac08e4 100644 --- a/native/cpp/compiler/HoverBuilder.cc +++ b/native/cpp/compiler/HoverBuilder.cc @@ -301,7 +301,6 @@ HoverResult& HoverBuilder::append_comment( const SourceLocation& source_location gExtensionConfiguration.showModuleFunctionComments ) { auto itr = workspace.builder_workspace.source_files.find( pathname ); - auto tokens = workspace.source->get_all_tokens(); if ( itr != workspace.builder_workspace.source_files.end() ) { auto sf = itr->second; diff --git a/native/cpp/compiler/SemanticContextBuilder.h b/native/cpp/compiler/SemanticContextBuilder.h index 6816c68..a665c63 100644 --- a/native/cpp/compiler/SemanticContextBuilder.h +++ b/native/cpp/compiler/SemanticContextBuilder.h @@ -1,6 +1,7 @@ #ifndef VSCODEESCRIPT_SEMANTICCONTEXTBUILDER_H #define VSCODEESCRIPT_SEMANTICCONTEXTBUILDER_H +#include "TokenLength.h" #include "bscript/compiler/ast/ClassDeclaration.h" #include "bscript/compiler/ast/ConstDeclaration.h" #include "bscript/compiler/ast/Expression.h" @@ -824,7 +825,7 @@ bool SemanticContextBuilder::contains( antlr4::Token* sym ) { if ( sym ) { - auto length = sym->getText().length(); + auto length = token_length( sym ); auto line_number = sym->getLine(); auto character_column = sym->getCharPositionInLine() + 1; if ( line_number == position.line_number && character_column <= position.character_column && @@ -906,9 +907,24 @@ antlrcpp::Any SemanticContextBuilder::visitChildren( antlr4::tree::ParseTree* { if ( auto* ctx = dynamic_cast( child ) ) { - Pol::Bscript::Compiler::Range range( *ctx ); - if ( range.contains( position ) ) + // A rule context spans all of its children, so if the position is outside + // this one it is outside everything below it. Descending anyway meant + // every hover, definition, completion, signature help and references + // request walked the file's entire parse tree -- with a dynamic_cast per + // node -- to collect what is only ever a single root-to-leaf chain. + // + // The guard is on the stop token, not just on contains(): an incomplete + // rule has no stop token, calculate_end_position() gives it end {0,0}, and + // contains() is then false for every position. That is routine while the + // user is mid-keystroke, so such a subtree has to be descended into or + // completion would break on exactly the half-typed code it serves. + if ( ctx->getStart() && ctx->getStop() ) { + Pol::Bscript::Compiler::Range range( *ctx ); + if ( !range.contains( position ) ) + { + continue; + } nodes.push_back( ctx ); } } diff --git a/native/cpp/compiler/TokenLength.h b/native/cpp/compiler/TokenLength.h new file mode 100644 index 0000000..3b7726a --- /dev/null +++ b/native/cpp/compiler/TokenLength.h @@ -0,0 +1,45 @@ +#ifndef VSCODEESCRIPT_TOKENLENGTH_H +#define VSCODEESCRIPT_TOKENLENGTH_H + +#include +#include + +namespace VSCodeEscript::CompilerExt +{ +/** + * Length of a token in characters, without materializing its text. + * + * The position tests in the builders only ever needed `getText().length()`, but + * `CommonToken::getText()` copies a substring out of the input stream, so asking + * for a length allocated a `std::string` per token examined. Those tests run + * across every token in the file on hover, completion and signature help. + * + * Deliberately only two virtual calls. An earlier version also fetched the + * input stream and its size to decide whether the indices were usable, and + * benchmarked no faster than `getText()`: a token short enough for the + * string's small-buffer optimisation never reaches the heap, so the allocation + * this was meant to avoid mostly was not happening, while the extra virtual + * dispatch was. + * + * For any token spanning real input the text is the inclusive interval + * `[start, stop]`. A zero-width token -- EOF above all -- carries + * `stop == start - 1`, which would wrap on the unsigned subtraction, so those + * fall through to `getText()` and match its "" exactly. + */ +inline std::size_t token_length( const antlr4::Token* token ) +{ + if ( !token ) + return 0; + + auto start = token->getStartIndex(); + auto stop = token->getStopIndex(); + + if ( stop >= start ) + return stop - start + 1; + + return token->getText().length(); +} + +} // namespace VSCodeEscript::CompilerExt + +#endif // VSCODEESCRIPT_TOKENLENGTH_H From e2785ed3fb8d592e58a45db83d844af93cb89434 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:20:31 +0200 Subject: [PATCH 09/15] Add a benchmark harness There was no way to measure any of this: no benchmark, no timing, and the compiler's counters were read by exactly one test. This drives analyze and each per-request builder over a small fixture and a 1610-line one, and prints wall-clock per operation alongside the profile delta. Gated behind JEST_RUN_LONG_TESTS (npm run test-all) like the other slow suites, since each case runs hundreds of iterations. describe.skip rather than a no-op gate, because every case here is gated and a no-op would leave the file with no tests at all, which Jest fails outright. Two things it asserts despite being a benchmark: that the probe positions actually resolve a symbol, and that the per-request cases do not move the compiler counters. A position that misses makes every builder bail immediately and turns the whole thing into a measurement of nothing, which is how the first version of it read -- and counters that move mean something is re-compiling per request rather than reading the analyzed tree. The large fixture exists because the small one cannot show the parse-tree walk: with ~17 lines there is nothing to skip. --- native/test/benchmark.spec.ts | 229 ++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 native/test/benchmark.spec.ts diff --git a/native/test/benchmark.spec.ts b/native/test/benchmark.spec.ts new file mode 100644 index 0000000..6211aba --- /dev/null +++ b/native/test/benchmark.spec.ts @@ -0,0 +1,229 @@ +import { resolve } from 'path'; +import { readFileSync } from 'fs'; +import { native, type CompilerProfile } from '../src/index'; + +const { LSPWorkspace } = native; + +/** + * Performance harness, not a correctness test: it asserts almost nothing and + * exists to produce comparable numbers before and after a change. + * + * Gated behind JEST_RUN_LONG_TESTS (`npm run test-all`) like the other slow + * suites, since it deliberately runs each operation hundreds of times. + */ +// describe.skip rather than a no-op: every test here is gated, so a no-op would +// leave the suite with no tests at all and Jest fails the file outright. +const describeLongTest = process.env['JEST_RUN_LONG_TESTS'] ? describe : describe.skip; + +const dir = resolve(__dirname); + +/** + * Fields worth reporting, in the order they occur in a compile. The rest of + * CompilerProfile is still collected; these are the ones that move. + */ +const REPORTED: ReadonlyArray = [ + 'buildWorkspaceMicros', + 'parseSrcMicros', + 'astSrcMicros', + 'parseIncMicros', + 'astIncMicros', + 'parseEmMicros', + 'astEmMicros', + 'astResolveFunctionsMicros', + 'optimizeMicros', + 'disambiguateMicros', + 'analyzeMicros', + 'tokenizeMicros', + 'cacheHits', + 'cacheMisses', + 'ambiguities' +]; + +function delta(before: CompilerProfile, after: CompilerProfile): string { + return REPORTED + .map(key => { + // Null-coalesced so the harness can also run against a build whose + // GetProfile predates these counters. + const value = (after[key] ?? 0) - (before[key] ?? 0); + return key.endsWith('Micros') + ? `${key.slice(0, -'Micros'.length)}=${(value / 1000).toFixed(1)}ms` + : `${key}=${value}`; + }) + .join(' '); +} + +/** Wall-clock milliseconds per iteration. */ +function time(iterations: number, fn: (iteration: number) => void): number { + const startedAt = process.hrtime.bigint(); + for (let i = 0; i < iterations; i++) { + fn(i); + } + return Number(process.hrtime.bigint() - startedAt) / 1e6 / iterations; +} + +describeLongTest('Benchmarks', () => { + // Deep enough to exercise the include and module chains, which is where the + // parse-tree caches and the AST rebuild on top of them show up. + const source = `use basicio; +use os; +include "testutil"; + +function helper( a, b := 2 ) + var total := a + b; + foreach entry in ( { 1, 2, 3 } ) + total := total + entry; + endforeach + return total; +endfunction + +program bench( args ) + var result := helper( 1 ); + Print( result ); + Print( args ); +endprogram +`; + + let text = source; + const src = resolve(dir, 'benchmark-probe.src'); + + const workspace = new LSPWorkspace({ + getContents: (pathname: string) => pathname === src ? text : readFileSync(pathname, 'utf-8') + }); + + const report = (label: string, perOp: number, before: CompilerProfile, after: CompilerProfile) => { + console.log(`\n ${label}: ${perOp.toFixed(3)} ms/op\n ${delta(before, after)}`); + }; + + beforeAll(() => { + workspace.open(dir); + }); + + it('analyze', () => { + const document = workspace.getDocument(src); + document.analyze(); + + const before = { ...workspace.profile }; + // Vary the text so nothing can memoize the root file, the way an editing + // session does. + const perOp = time(200, i => { + text = source.replace('var result := helper( 1 );', `var result := helper( ${i} );`); + document.analyze(); + }); + const after = { ...workspace.profile }; + + report('analyze', perOp, before, after); + expect(perOp).toBeGreaterThan(0); + }); + + // The small fixture above cannot show the parse-tree walk cost: with ~17 + // lines there is nothing to skip. This one is sized like a real script, and + // probes near the end of it, which is where a walk that visits every node + // costs the most and a walk pruned by position costs the same as anywhere. + it('per-request on a large file', () => { + const functions = 200; + const lines: string[] = ['use basicio;', 'include "testutil";', '']; + + for (let i = 0; i < functions; i++) { + lines.push( + `function generated_${i}( a, b := ${i} )`, + ` var total_${i} := a + b;`, + ` foreach entry in ( { 1, 2, 3 } )`, + ` total_${i} := total_${i} + entry;`, + ` endforeach`, + ` return total_${i};`, + `endfunction`, + '' + ); + } + + lines.push('program big( args )', ' var result := generated_0( 1 );'); + // The probe line: last call in the program, far from the root. + const probeLine = lines.push(` result := generated_${functions - 1}( result );`); + lines.push(' Print( result );', ' Print( args );', 'endprogram', ''); + + let bigText = lines.join('\n'); + + const bigSrc = resolve(dir, 'benchmark-big.src'); + const bigWorkspace = new LSPWorkspace({ + getContents: (pathname: string) => pathname === bigSrc ? bigText : readFileSync(pathname, 'utf-8') + }); + bigWorkspace.open(dir); + + const document = bigWorkspace.getDocument(bigSrc); + document.analyze(); + + // ` result := generated_199( result );` -- inside the callee name. + const position = { line: probeLine, character: 20 }; + expect(document.hover(position)).toBeDefined(); + expect(document.definition(position)).toBeDefined(); + + const before = { ...bigWorkspace.profile }; + const results = { + analyze: time(20, () => { document.analyze(); }), + hover: time(200, () => { document.hover(position); }), + definition: time(200, () => { document.definition(position); }), + completion: time(200, () => { document.completion(position); }), + tokens: time(200, () => { document.tokens(); }), + symbols: time(200, () => { document.symbols(); }) + }; + const after = { ...bigWorkspace.profile }; + + console.log(` + large file (${lines.length} lines, ${functions} functions):`); + for (const [name, perOp] of Object.entries(results)) { + console.log(` ${name.padEnd(15)} ${perOp.toFixed(4)} ms/op`); + } + console.log(` ${delta(before, after)}`); + + for (const perOp of Object.values(results)) { + expect(perOp).toBeGreaterThan(0); + } + }); + + it('hover, definition, completion, signatureHelp, tokens, symbols', () => { + text = source; + const document = workspace.getDocument(src); + document.analyze(); + + // Inside the `helper` call on line 14, columns 19-24. Both are 1-based + // here: the addon takes the position the way connection.ts hands it + // over, already offset from LSP's 0-based coordinates. A position that + // misses a symbol makes every builder bail immediately and the whole + // benchmark measure nothing. + const position = { line: 14, character: 21 }; + + // Signature help wants a caret inside the argument list, not on the + // callee, and resolves nothing at column 21. + const signaturePosition = { line: 14, character: 27 }; + // A position that misses makes every builder bail immediately, which + // would quietly turn this into a benchmark of nothing. + expect(document.hover(position)).toBeDefined(); + expect(document.definition(position)).toBeDefined(); + expect(document.signatureHelp(signaturePosition)).toBeDefined(); + + const before = { ...workspace.profile }; + + const results = { + hover: time(500, () => { document.hover(position); }), + definition: time(500, () => { document.definition(position); }), + completion: time(500, () => { document.completion(position); }), + signatureHelp: time(500, () => { document.signatureHelp(signaturePosition); }), + tokens: time(500, () => { document.tokens(); }), + symbols: time(500, () => { document.symbols(); }) + }; + + const after = { ...workspace.profile }; + + // These read an already-analyzed tree, so the compiler counters should + // barely move -- if they do, something is re-compiling per request. + console.log('\n per-request (no re-analysis):'); + for (const [name, perOp] of Object.entries(results)) { + console.log(` ${name.padEnd(15)} ${perOp.toFixed(4)} ms/op`); + } + console.log(` ${delta(before, after)}`); + + for (const perOp of Object.values(results)) { + expect(perOp).toBeGreaterThan(0); + } + }); +}); From e75e4c4e06f01b6d784ab1187f6ed2a6756ce676 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:20:40 +0200 Subject: [PATCH 10/15] Enable link-time optimization for Release bscript, plib, libtinyxml and the addon all link statically into a single .node, so the whole compiler is visible to the optimizer at once. Release already builds at /O2 /Ob2 /DNDEBUG, so this is the remaining codegen lever. The binary comes out ~48 KB smaller. IPO is set on the polserver targets from here rather than inside the submodule, which stays untouched. antlr is an ExternalProject with its own build and is not covered. /GL coexists with the /Zi + /DEBUG + /OPT:REF + /OPT:ICF the previous round established: dumpbin still matches the PDB to the binary, so a released crash dump stays symbolisable. That is worth re-checking after any change here, since an unsymbolisable Release build is the exact problem that setup was added to fix. The contribution to run time is not separately measured -- analyze() is unchanged within run-to-run variance, and the per-request wins in this branch come from the builder changes, not from this. --- native/CMakeLists.txt | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index abeaa92..cee04cf 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -88,3 +88,35 @@ target_link_libraries(${PROJECT_NAME} PRIVATE plib libtinyxml ) + +# Link-time optimization. +# +# bscript, plib, libtinyxml and the addon itself all link statically into a +# single .node, so the whole compiler is visible to the optimizer at once -- +# exactly the shape LTO is good at, and the remaining codegen lever now that +# Release already builds at /O2 /Ob2 /DNDEBUG. +# +# IPO is set on the polserver targets from here rather than inside the submodule +# so the vendored tree stays untouched. antlr is an ExternalProject with its own +# build and is not covered by this. +# +# /GL coexists with the /Zi + /DEBUG + /OPT:REF + /OPT:ICF above: the PDB is +# still emitted, so a released crash dump stays symbolisable. Verify that after +# changing anything here -- an unsymbolisable Release build is the exact problem +# that setup was added to fix. +option(ESCRIPT_LTO "Enable link-time optimization for Release builds" ON) + +if(ESCRIPT_LTO) + include(CheckIPOSupported) + check_ipo_supported(RESULT ESCRIPT_IPO_SUPPORTED OUTPUT ESCRIPT_IPO_ERROR) + if(ESCRIPT_IPO_SUPPORTED) + foreach(LTO_TARGET ${PROJECT_NAME} bscript plib libtinyxml) + if(TARGET ${LTO_TARGET}) + set_property(TARGET ${LTO_TARGET} PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + endif() + endforeach() + message("** Link-time optimization enabled for Release") + else() + message(WARNING "** Link-time optimization unavailable: ${ESCRIPT_IPO_ERROR}") + endif() +endif() From 6ab7de387c11bf8d4d1371a29cdef290f680aaff Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:21:02 +0200 Subject: [PATCH 11/15] Bundle the extension, narrow activation Nothing was bundled: the build was tsc -b only, and ts-loader sat in devDependencies with no webpack config anywhere in the tree. The packaged .vsix carried 817 entries and 6.86 MB, of which the project's own code was 10 files and 67 KB. The rest was three near-duplicate copies of the vscode-jsonrpc / vscode-languageserver trees, and the server resolved and parsed all of it on every boot. Bundling with esbuild to dist/ brings that to 16 entries and 3.90 MB, of which 2.59 MB is the addon; the JS payload goes from ~4.3 MB across ~750 files to two bundles totalling 1.25 MB. vscode and *.node stay external. The addon is located at runtime through a computed require() that esbuild leaves alone, and native/src/index.ts already searches the layouts the bundle can run from. esbuild preserves the one ordering that matters: server/src/index.ts requires ./server/connection rather than importing it, so the native addon loads after installCrashHandlers(). The bundle turns that into a lazily initialised module and the call still happens first. Verified in the output, and by loading the bundled server. Packaging fixes that come with it: - .vscodeignore had no server/node_modules rule at all, so 264 files shipped, including async/dist/async.js and async.mjs -- 425 KB for one function. It also excluded **/*.d.ts and then re-included 113 of them, 647 KB, through the client/node_modules negations. None of that is needed now that nothing ships out of node_modules, so the negations are gone. - activationEvents said onDebug, which fires for any debug session of any type in the window. Narrowed to the two events this extension's debugger contribution actually needs. - Activation no longer forks the server unconditionally. contributes.languages binds escriptcfg to .cfg, so opening a setup.cfg in an unrelated project activated the extension, forked Node and dlopened the 2.59 MB addon before the server discovered there was no pol.cfg to open. The client now applies the same test the server already applied in onInitialize, and re-checks it if a folder is added later. - Drop ts-loader, which removes webpack's dependency tree from the lockfile. --- .vscodeignore | 41 +- client/src/extension.ts | 80 +- esbuild.mjs | 44 + eslint.config.js | 2 + package-lock.json | 1860 +++++++++++++++------------------------ package.json | 12 +- 6 files changed, 866 insertions(+), 1173 deletions(-) create mode 100644 esbuild.mjs diff --git a/.vscodeignore b/.vscodeignore index 7b97207..e2a9a4b 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,30 +1,47 @@ +# Sources, build inputs and test material. .vscode/** +.github/** +.gitignore doc-assets/**/* +docs/** **/*.ts **/*.map -**/coverage **/*.d.ts -.gitignore +**/coverage **/tsconfig.json **/tsconfig.base.json -.github/** +**/package-lock.json +**/*.tsbuildinfo +.gitmodules +native/jest.config.js client/test/** -client/node_modules/** -!client/node_modules/vscode-jsonrpc/** -!client/node_modules/vscode-languageclient/** -!client/node_modules/vscode-languageserver-protocol/** -!client/node_modules/vscode-languageserver-types/** -!client/node_modules/{minimatch,brace-expansion,concat-map,balanced-match}/** -!client/node_modules/{semver,lru-cache,yallist}/** server/test/** native/test/** client/jest.e2e.config.js server/jest.config.js +jest.config.js +eslint.config.js +esbuild.mjs +grammars/*.yml + +# The client and server ship as the two bundles in dist/. Everything the old +# unbundled layout needed -- tsc output next to three near-duplicate copies of +# the vscode-languageserver trees -- is dead weight now, so no negation rules +# are needed to carve exceptions out of node_modules. +client/out/** +server/out/** +native/out/** +node_modules/** +client/node_modules/** +server/node_modules/** +native/node_modules/** + +# Native addon: ship the built binary, not the sources, the CMake tree or the +# ~40 MB PDB that Release builds now emit. native/.clang-format native/CMakeLists.txt native/cpp/** native/prebuilds/** +native/polserver/** native/build/** !native/build/Release/*.node -native/polserver/** -native/node_modules/** diff --git a/client/src/extension.ts b/client/src/extension.ts index b84bf23..5f72912 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -4,32 +4,57 @@ * ------------------------------------------------------------------------------------------ */ import * as path from 'path'; -import { workspace, ExtensionContext } from 'vscode'; +import { workspace, ExtensionContext, Uri, Disposable } from 'vscode'; import { activatePolDebug } from './activatePolDebug'; import { LanguageClient, LanguageClientOptions, ServerOptions, - State, TransportKind } from 'vscode-languageclient/node'; -let client: LanguageClient; +let client: LanguageClient | undefined; -export function activate(context: ExtensionContext) { +/** + * Whether any workspace folder is a POL distribution. + * + * This is the same test the server applies in `onInitialize`: without both files + * it never calls `workspace.open()`, so `compilercfg` is never read and no + * analysis can work. Checking here as well means a workspace the server would + * have rejected does not get a forked Node process and a 2.6 MB native addon + * loaded into it first. + * + * It matters because the extension activates more widely than it can serve: + * `contributes.languages` binds `escriptcfg` to `.cfg`, so opening a `setup.cfg` + * in an unrelated project activates this extension. + */ +async function isPolWorkspace(): Promise { + for (const folder of workspace.workspaceFolders ?? []) { + try { + await workspace.fs.stat(Uri.joinPath(folder.uri, 'pol.cfg')); + await workspace.fs.stat(Uri.joinPath(folder.uri, 'scripts', 'ecompile.cfg')); + return true; + } catch { + // Not a POL distribution; try the next folder. + } + } + return false; +} + +function startClient(context: ExtensionContext): LanguageClient { // The server is implemented in node - let serverModule = context.asAbsolutePath( - path.join('server', 'out', 'index.js') + const serverModule = context.asAbsolutePath( + path.join('dist', 'server.js') ); // The debug options for the server // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging - let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] }; + const debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] }; const args = [`--storageUri=${context.storageUri}`]; // If the extension is launched in debug mode then the debug server options are used // Otherwise the run options are used - let serverOptions: ServerOptions = { + const serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc, args }, debug: { module: serverModule, @@ -40,7 +65,7 @@ export function activate(context: ExtensionContext) { }; // Options to control the language client - let clientOptions: LanguageClientOptions = { + const clientOptions: LanguageClientOptions = { // Register the server for plain text documents documentSelector: [{ scheme: 'file', language: 'escript' }], synchronize: { @@ -52,8 +77,7 @@ export function activate(context: ExtensionContext) { } }; - // Create the language client and start the client. - client = new LanguageClient( + const started = new LanguageClient( 'escript', 'EScript Language Server', serverOptions, @@ -61,8 +85,14 @@ export function activate(context: ExtensionContext) { ); // Start the client. This will also launch the server - client.start(); + started.start(); + return started; +} + +export async function activate(context: ExtensionContext) { + // Cheap and unconditional: registering the debug providers costs nothing and + // must happen before a 'pol' session is resolved. activatePolDebug(context); workspace.onDidChangeConfiguration(e => { @@ -72,10 +102,34 @@ export function activate(context: ExtensionContext) { if (!e.affectsConfiguration('escript')) { return; } - client.sendNotification('didChangeConfiguration', { + client?.sendNotification('didChangeConfiguration', { configuration: workspace.getConfiguration('escript') }); + }, undefined, context.subscriptions); + + const startIfPol = async () => { + if (client || !await isPolWorkspace()) { + return; + } + client = startClient(context); + }; + + // A POL distribution can be added to the window after activation, so the + // check has to be repeated rather than made once at startup. + let folderWatch: Disposable | undefined = workspace.onDidChangeWorkspaceFolders(async () => { + await startIfPol(); + if (client) { + folderWatch?.dispose(); + folderWatch = undefined; + } }); + context.subscriptions.push({ dispose: () => folderWatch?.dispose() }); + + await startIfPol(); + if (client) { + folderWatch.dispose(); + folderWatch = undefined; + } } export function deactivate(): Thenable | undefined { diff --git a/esbuild.mjs b/esbuild.mjs new file mode 100644 index 0000000..c8ec1f0 --- /dev/null +++ b/esbuild.mjs @@ -0,0 +1,44 @@ +/** + * Bundles the extension host client and the language server. + * + * Before this, both shipped as raw `tsc` output next to their own + * `node_modules`, so the `.vsix` carried three near-identical copies of the + * `vscode-jsonrpc` / `vscode-languageserver-*` trees -- ~750 files and ~4.3 MB + * to support ~67 KB of project code -- and the server paid for resolving and + * parsing all of it on every boot. + * + * Two things are deliberately left external: + * + * - `vscode`, which is injected by the extension host and has no package. + * - `*.node`, the compiled addon. `native/src/index.ts` locates it at runtime + * through a computed `require()`, which esbuild leaves alone; the roots it + * searches account for this file's bundled location. + */ +import { build } from 'esbuild'; + +const production = !process.argv.includes('--watch'); + +/** @type {import('esbuild').BuildOptions} */ +const common = { + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + external: ['vscode', '*.node'], + minify: false, + sourcemap: production ? false : 'inline', + logLevel: 'info' +}; + +await Promise.all([ + build({ + ...common, + entryPoints: ['client/src/extension.ts'], + outfile: 'dist/extension.js' + }), + build({ + ...common, + entryPoints: ['server/src/index.ts'], + outfile: 'dist/server.js' + }) +]); diff --git a/eslint.config.js b/eslint.config.js index 3becb13..9435603 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,8 @@ module.exports = [ 'native/node_modules/**', 'native/polserver/**', 'native/out/**', + // Generated bundles, not sources. + 'dist/**', 'coverage', ] } diff --git a/package-lock.json b/package-lock.json index eb5d1f5..d729581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,13 +18,13 @@ "@types/node": "^22.14.1", "@typescript-eslint/parser": "^8.29.1", "cross-env": "^7.0.3", + "esbuild": "^0.28.2", "eslint": "^9.24.0", "jest": "^29.7.0", "js-yaml": "^4.1.0", "mock-fs": "^5.5.0", "rimraf": "^6.0.1", "ts-jest": "^29.3.2", - "ts-loader": "^9.5.2", "typescript": "^5.8.3" }, "engines": { @@ -500,6 +500,448 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -1258,18 +1700,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -1397,30 +1827,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1638,221 +2044,29 @@ "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.29.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" + "@typescript-eslint/types": "8.29.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, "license": "Apache-2.0", - "peer": true + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, "node_modules/acorn": { "version": "8.14.0", @@ -1893,17 +2107,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2221,16 +2424,6 @@ "node": ">=10" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -2304,14 +2497,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2498,20 +2683,6 @@ "dev": true, "license": "MIT" }, - "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -2522,12 +2693,47 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-module-lexer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", - "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, - "peer": true + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } }, "node_modules/escalade": { "version": "3.2.0", @@ -2613,20 +2819,6 @@ } } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -2768,16 +2960,6 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2788,16 +2970,6 @@ "node": ">=0.10.0" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3149,14 +3321,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4211,16 +4375,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6.11.5" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4322,29 +4476,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4399,13 +4530,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "peer": true - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4822,17 +4946,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -5015,48 +5128,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -5069,17 +5140,6 @@ "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5132,18 +5192,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5301,104 +5349,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/terser": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", - "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5509,37 +5459,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ts-loader": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5720,79 +5639,6 @@ "makeerror": "1.0.12" } }, - "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack": { - "version": "5.96.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", - "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6266,6 +6112,188 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "dev": true, + "optional": true + }, "@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -6799,17 +6827,6 @@ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, - "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "peer": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -6917,28 +6934,6 @@ "@babel/types": "^7.20.7" } }, - "@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "peer": true, - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "peer": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -7116,181 +7111,6 @@ } } }, - "@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "peer": true - }, - "@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "peer": true - }, - "@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "peer": true - }, - "@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "peer": true - }, - "@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "peer": true, - "requires": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "peer": true, - "requires": { - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "peer": true - }, - "@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "peer": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "peer": true - }, - "@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "peer": true - }, "acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -7316,14 +7136,6 @@ "uri-js": "^4.2.2" } }, - "ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peer": true, - "requires": {} - }, "ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7534,13 +7346,6 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, - "chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "peer": true - }, "ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -7591,13 +7396,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7718,16 +7516,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - } - }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7737,12 +7525,39 @@ "is-arrayish": "^0.2.1" } }, - "es-module-lexer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", - "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", - "dev": true, - "peer": true + "esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } }, "escalade": { "version": "3.2.0", @@ -7823,17 +7638,6 @@ } } }, - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "peer": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, "eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -7899,26 +7703,12 @@ } } }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "peer": true - }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "peer": true - }, "execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8172,13 +7962,6 @@ "is-glob": "^4.0.3" } }, - "glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "peer": true - }, "globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -8932,13 +8715,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, - "loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "peer": true - }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9015,23 +8791,6 @@ "picomatch": "^2.3.1" } }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "peer": true - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "peer": true, - "requires": { - "mime-db": "1.52.0" - } - }, "mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -9071,13 +8830,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "peer": true - }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9349,16 +9101,6 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "peer": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -9470,40 +9212,11 @@ "queue-microtask": "^1.2.2" } }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "peer": true - }, - "schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "peer": true, - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - } - }, "semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" }, - "serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "peer": true, - "requires": { - "randombytes": "^2.1.0" - } - }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9543,17 +9256,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "peer": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9660,63 +9362,6 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, - "tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true - }, - "terser": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", - "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", - "dev": true, - "peer": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - } - }, - "terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", - "dev": true, - "peer": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.20", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" - }, - "dependencies": { - "jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "peer": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - } - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "peer": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9776,27 +9421,6 @@ } } }, - "ts-loader": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "dependencies": { - "source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true - } - } - }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9916,56 +9540,6 @@ "makeerror": "1.0.12" } }, - "watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, - "peer": true, - "requires": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - } - }, - "webpack": { - "version": "5.96.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", - "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", - "dev": true, - "peer": true, - "requires": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - } - }, - "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "peer": true - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c8699ff..c4907e5 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,14 @@ "vscode": "^1.78.0" }, "activationEvents": [ - "onDebug" + "onDebugResolve:pol", + "onDebugDynamicConfigurations:pol" ], "repository": { "type": "git", "url": "https://github.com/polserver/vscode-escript" }, - "main": "./client/out/extension.js", + "main": "./dist/extension.js", "contributes": { "languages": [ { @@ -248,8 +249,9 @@ "vscode:prepublish": "npm run clean && npm run build", "postinstall": "cd client && npm install && cd ../native && npm install && cd ../server && npm install", "watch": "npm run grammar && tsc -b -w", - "build": "npm run grammar && tsc -b && cd native && npm run build", - "clean": "rimraf client/out server/out grammars/escript.json", + "bundle": "node esbuild.mjs", + "build": "npm run grammar && tsc -b && node esbuild.mjs && cd native && npm run build", + "clean": "rimraf client/out server/out dist grammars/escript.json", "e2e": "cd client && npm test", "test": "jest --coverage --detectOpenHandles --runInBand --testTimeout=15000", "test-all": "cross-env JEST_RUN_LONG_TESTS=1 jest --coverage --detectOpenHandles --runInBand --testTimeout=15000", @@ -265,13 +267,13 @@ "@types/node": "^22.14.1", "@typescript-eslint/parser": "^8.29.1", "cross-env": "^7.0.3", + "esbuild": "^0.28.2", "eslint": "^9.24.0", "jest": "^29.7.0", "js-yaml": "^4.1.0", "mock-fs": "^5.5.0", "rimraf": "^6.0.1", "ts-jest": "^29.3.2", - "ts-loader": "^9.5.2", "typescript": "^5.8.3" } } From 09a9900bf9ec337f476097120fbab5876a076e53 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:39:10 +0200 Subject: [PATCH 12/15] Invalidate stale analyses on close, include edits and reopen - Keep a document dirty until analyze() and the dependency refresh succeed; a throw used to leave an empty tree treated as clean. - Mark the parse-tree cache stale when an open .inc/.em changes, and clear it before whichever analysis runs next, so re-analyzing an including .src first cannot reuse the pre-edit tree. - On close, dirty documents including the closed buffer and drop the cached tree of an unsaved .inc/.em. - After a config reopen with changes, dirty every retained document. - Drop the analysis before formatting; the formatter runs its own compiler. --- server/src/server/connection.ts | 37 +++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/server/src/server/connection.ts b/server/src/server/connection.ts index f50b0aa..2097065 100644 --- a/server/src/server/connection.ts +++ b/server/src/server/connection.ts @@ -61,6 +61,14 @@ export class LSPServer { * across N-API for every open document on every keystroke. */ private dependsOn: Map = new Map(); + + /** + * Set when an include or module buffer changes, and honored by the next + * analysis of *any* document: whichever one is analyzed first -- the edited + * file or a `.src` including it -- must not reuse the pre-edit tree. Kept as + * a flag so typing in a `.inc` costs no N-API call per keystroke. + */ + private parseTreeCacheStale = false; private downloader: DocsDownloader; private configuration: ExtensionConfiguration | undefined; private updateCacheAbortController: AbortController | undefined; @@ -337,14 +345,18 @@ export class LSPServer { if (!this.dirty.has(fsPath)) { return; } - this.dirty.delete(fsPath); - if (isCachedParseTreeFile(fsPath)) { + if (this.parseTreeCacheStale) { this.workspace.clearParseTreeCache(); + this.parseTreeCacheStale = false; } + // analyze() drops the old syntax tree before compiling, so the document + // stays dirty until it succeeds: a throw here must not leave an empty + // tree that later requests treat as up to date. document.analyze(this.configuration?.continueAnalysisOnError); this.dependsOn.set(fsPath, document.dependents()); + this.dirty.delete(fsPath); } private onDidOpen = async (e: TextDocumentChangeEvent) => { @@ -361,6 +373,15 @@ export class LSPServer { const { fsPath } = URI.parse(uri); const document = this.sources.get(fsPath); + + // Closing discards any unsaved edits, so contents fall back to disk: + // whatever was analyzed from the buffer -- its cached parse tree, and + // every open document including it -- is now stale. + this.markDirty(fsPath); + if (isCachedParseTreeFile(fsPath)) { + this.parseTreeCacheStale = true; + } + this.sources.delete(fsPath); this.dirty.delete(fsPath); this.dependsOn.delete(fsPath); @@ -383,6 +404,9 @@ export class LSPServer { // Deliberately does no work here: the next request that needs the tree // triggers exactly one analysis. + if (isCachedParseTreeFile(fsPath)) { + this.parseTreeCacheStale = true; + } this.markDirty(fsPath); }; @@ -458,6 +482,13 @@ export class LSPServer { // references later does not resurrect a stale index. this.guard('workspace/invalidateReferenceCache', () => this.workspace.invalidateReferenceCache(), undefined); + // Every retained analysis was built against the old + // configuration. + this.parseTreeCacheStale = true; + for (const pathname of this.sources.keys()) { + this.dirty.add(pathname); + } + if (this.configuration?.disableWorkspaceReferences === false) { this.updateCache(); } @@ -721,8 +752,6 @@ export class LSPServer { return null; } - this.guard('textDocument/formatting/analyze', () => this.ensureAnalyzed(fsPath, document), undefined, fsPath); - const formatted = this.guard('textDocument/formatting', () => range ? document.toFormattedString(options, { start: { line: range.start.line + 1, character: range.start.character }, From 26930a67d60994bd23e9cbe5611b7fefccefa252 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:39:10 +0200 Subject: [PATCH 13/15] Create the report directory before enabling crash reports On a fresh workspace the storage directory only appeared in onInitialize, after the addon load these reports exist to diagnose. --- server/src/misc/Diagnostics.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/misc/Diagnostics.ts b/server/src/misc/Diagnostics.ts index d39865f..f65eba7 100644 --- a/server/src/misc/Diagnostics.ts +++ b/server/src/misc/Diagnostics.ts @@ -8,6 +8,8 @@ * the crash handlers below print them on the way out. */ +import { mkdirSync } from 'fs'; + export type BreadcrumbPosition = { line: number; character: number; @@ -108,6 +110,10 @@ export function installCrashHandlers(storageFsPath: string): void { // Node's diagnostic report captures a *native* stack on fatal errors, which // is the only way to see where an abort inside the addon came from. try { + // Created here rather than waiting for onInitialize: the window this is + // for includes loading the addon, and Node will not write a report into + // a directory that does not exist yet. + mkdirSync(storageFsPath, { recursive: true }); process.report.directory = storageFsPath; process.report.filename = ''; process.report.reportOnFatalError = true; From 41772ae5c1b508a7acd3c217622fe604c5674af4 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:39:11 +0200 Subject: [PATCH 14/15] Recheck the client after the POL workspace probe The activation call and a folder event could both pass the first check before either probe resolved, starting two language clients. --- client/src/extension.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/src/extension.ts b/client/src/extension.ts index 5f72912..be8e55b 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -111,6 +111,11 @@ export async function activate(context: ExtensionContext) { if (client || !await isPolWorkspace()) { return; } + // Checked again: the activation-time call and a folder event can both + // pass the first check before either probe resolves. + if (client) { + return; + } client = startClient(context); }; From ff1e5b26f90d04b80b05ff0affb674d04f29566e Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:39:11 +0200 Subject: [PATCH 15/15] Make the debug watch rebuild dist/ The extension now loads dist/, but the launch task ran only tsc -b -w. 'watch' now runs esbuild in watch mode; 'watch:tsc' keeps type errors in the Problems panel, and the launch waits on both. --- .vscode/launch.json | 7 ++----- .vscode/tasks.json | 36 +++++++++++++++++++++++++++++++++--- esbuild.mjs | 25 +++++++++++++++++-------- package.json | 3 ++- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 9ef961f..d9cb39e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,11 +8,8 @@ "name": "Launch Client", "runtimeExecutable": "${execPath}", "args": ["--extensionDevelopmentPath=${workspaceRoot}"], - "outFiles": ["${workspaceRoot}/client/out/**/*.js"], - "preLaunchTask": { - "type": "npm", - "script": "watch" - } + "outFiles": ["${workspaceRoot}/dist/**/*.js"], + "preLaunchTask": "watch-all" }, { "name": "Attach to Language Server", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 070d88e..7924c95 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,10 +17,28 @@ "type": "npm", "script": "watch", "isBackground": true, - "group": { - "kind": "build", - "isDefault": true + "presentation": { + "panel": "dedicated", + "reveal": "never" }, + "problemMatcher": { + "owner": "esbuild", + "fileLocation": "relative", + "pattern": { + "regexp": "^✘ \\[ERROR\\] (.*)$", + "message": 1 + }, + "background": { + "activeBegins": true, + "beginsPattern": "\\[watch\\] build started", + "endsPattern": "\\[watch\\] build finished" + } + } + }, + { + "type": "npm", + "script": "watch:tsc", + "isBackground": true, "presentation": { "panel": "dedicated", "reveal": "never" @@ -28,6 +46,18 @@ "problemMatcher": [ "$tsc-watch" ] + }, + { + "label": "watch-all", + "dependsOn": [ + "npm: watch", + "npm: watch:tsc" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/esbuild.mjs b/esbuild.mjs index c8ec1f0..fd8975f 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -14,9 +14,10 @@ * through a computed `require()`, which esbuild leaves alone; the roots it * searches account for this file's bundled location. */ -import { build } from 'esbuild'; +import { build, context } from 'esbuild'; -const production = !process.argv.includes('--watch'); +const watch = process.argv.includes('--watch'); +const production = !watch; /** @type {import('esbuild').BuildOptions} */ const common = { @@ -30,15 +31,23 @@ const common = { logLevel: 'info' }; -await Promise.all([ - build({ +const entries = [ + { ...common, entryPoints: ['client/src/extension.ts'], outfile: 'dist/extension.js' - }), - build({ + }, + { ...common, entryPoints: ['server/src/index.ts'], outfile: 'dist/server.js' - }) -]); + } +]; + +if (watch) { + // The Extension Development Host loads `dist/`, so the debug launch's watch + // task has to rebuild it; `tsc -b -w` alone only refreshes `*/out`. + await Promise.all(entries.map(async options => (await context(options)).watch())); +} else { + await Promise.all(entries.map(options => build(options))); +} diff --git a/package.json b/package.json index c4907e5..b72b964 100644 --- a/package.json +++ b/package.json @@ -248,7 +248,8 @@ "scripts": { "vscode:prepublish": "npm run clean && npm run build", "postinstall": "cd client && npm install && cd ../native && npm install && cd ../server && npm install", - "watch": "npm run grammar && tsc -b -w", + "watch": "npm run grammar && node esbuild.mjs --watch", + "watch:tsc": "tsc -b -w", "bundle": "node esbuild.mjs", "build": "npm run grammar && tsc -b && node esbuild.mjs && cd native && npm run build", "clean": "rimraf client/out server/out dist grammars/escript.json",